Merge branch 'master' into link-layers

This commit is contained in:
Alexander Wang 2023-02-28 14:28:33 -08:00
commit e83cf8d157
No known key found for this signature in database
GPG key ID: D89FA31966BDBECE
1113 changed files with 99571 additions and 36836 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@
e2e_report.html
bin
out
d2

View file

@ -7,17 +7,17 @@ all: fmt gen lint build test
fmt:
prefix "$@" ./ci/sub/bin/fmt.sh
.PHONY: gen
gen:
gen: fmt
prefix "$@" ./ci/gen.sh
.PHONY: lint
lint:
lint: fmt
prefix "$@" go vet --composites=false ./...
.PHONY: build
build:
build: fmt
prefix "$@" go build ./...
.PHONY: test
test:
test: fmt
prefix "$@" ./ci/test.sh
.PHONY: race
race:
race: fmt
prefix "$@" ./ci/test.sh --race ./...

View file

@ -4,7 +4,6 @@
A modern diagram scripting language that turns text to diagrams.
</h2>
[Docs](https://d2lang.com) | [Cheat sheet](./docs/assets/cheat_sheet.pdf) | [Comparisons](https://text-to-diagram.com) | [Playground](https://play.d2lang.com)
[![ci](https://github.com/terrastruct/d2/actions/workflows/ci.yml/badge.svg)](https://github.com/terrastruct/d2/actions/workflows/ci.yml)
@ -25,24 +24,24 @@ https://user-images.githubusercontent.com/3120367/206125010-bd1fea8e-248a-43e7-8
# Table of Contents
<!-- toc -->
- <a href="#what-does-d2-look-like" id="toc-what-does-d2-look-like">What does D2 look like?</a>
- <a href="#quickstart" id="toc-quickstart">Quickstart</a>
- <a href="#install" id="toc-install">Install</a>
- <a href="#d2-as-a-library" id="toc-d2-as-a-library">D2 as a library</a>
- <a href="#themes" id="toc-themes">Themes</a>
- <a href="#fonts" id="toc-fonts">Fonts</a>
- <a href="#export-file-types" id="toc-export-file-types">Export file types</a>
- <a href="#language-tooling" id="toc-language-tooling">Language tooling</a>
- <a href="#plugins" id="toc-plugins">Plugins</a>
- <a href="#comparison" id="toc-comparison">Comparison</a>
- <a href="#contributing" id="toc-contributing">Contributing</a>
- <a href="#license" id="toc-license">License</a>
- <a href="#related" id="toc-related">Related</a>
- <a href="#official-plugins" id="toc-official-plugins">Official plugins</a>
- <a href="#community-plugins" id="toc-community-plugins">Community plugins</a>
- <a href="#misc" id="toc-misc">Misc</a>
- <a href="#faq" id="toc-faq">FAQ</a>
- <a href="#open-source-projects-documenting-with-d2" id="toc-open-source-projects-documenting-with-d2">Open-source projects documenting with D2</a>
- [What does D2 look like?](#what-does-d2-look-like)
- [Quickstart](#quickstart)
- [Install](#install)
- [D2 as a library](#d2-as-a-library)
- [Themes](#themes)
- [Fonts](#fonts)
- [Export file types](#export-file-types)
- [Language tooling](#language-tooling)
- [Plugins](#plugins)
- [Comparison](#comparison)
- [Contributing](#contributing)
- [License](#license)
- [Related](#related)
- [Official plugins](#official-plugins)
- [Community plugins](#community-plugins)
- [Misc](#misc)
- [FAQ](#faq)
- [Open-source projects documenting with D2](#open-source-projects-documenting-with-d2)
## What does D2 look like?
@ -148,7 +147,7 @@ one, please see [./d2renderers/d2fonts](./d2renderers/d2fonts).
## Export file types
D2 currently supports SVG and PNG exports. More coming soon.
D2 currently supports SVG, PNG and PDF exports. More coming soon.
## Language tooling
@ -226,6 +225,7 @@ let us know and we'll be happy to include it here!
- **Confluence plugin**: [https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin](https://github.com/andrinmeier/unofficial-d2lang-confluence-plugin)
- **CIL (C#, Visual Basic, F#, C++ CLR) to D2**: [https://github.com/HugoVG/AppDiagram](https://github.com/HugoVG/AppDiagram)
- **D2 Snippets (for text editors)**: [https://github.com/Paracelsus-Rose/D2-Language-Code-Snippets](https://github.com/Paracelsus-Rose/D2-Language-Code-Snippets)
- **Mongo to D2**: [https://github.com/novuhq/mongo-to-D2](https://github.com/novuhq/mongo-to-D2)
### Misc

View file

@ -9,7 +9,13 @@ FORCE_COLOR=1 DEBUG=1 go run ./e2etests/report/main.go "$@";
if [ -z "${NO_OPEN:-}" ]; then
if [ -s "$REPORT_OUTPUT" ]; then
open "$REPORT_OUTPUT"
if command -v open >/dev/null; then
open "$REPORT_OUTPUT"
elif command -v xdg-open >/dev/null; then
xdg-open "$REPORT_OUTPUT"
else
echo "Please open $REPORT_OUTPUT"
fi
else
echo "The report is empty"
fi

View file

@ -252,10 +252,10 @@ install_post_standalone() {
Extend your \$PATH to use d2:
export PATH=$PREFIX/bin:\$PATH
Then run:
${TALA+D2_LAYOUT=tala }d2 --help
${TALA:+D2_LAYOUT=tala }d2 --help
EOF
else
log "Run ${TALA+D2_LAYOUT=tala }d2 --help for usage."
log "Run ${TALA:+D2_LAYOUT=tala }d2 --help for usage."
fi
if ! manpath 2>/dev/null | grep -qF "$PREFIX/share/man"; then
logcat >&2 <<EOF
@ -282,7 +282,7 @@ install_post_brew() {
fi
log "Rerun this install script with --uninstall to uninstall."
log
log "Run ${TALA+D2_LAYOUT=tala }d2 --help for usage."
log "Run ${TALA:+D2_LAYOUT=tala }d2 --help for usage."
log "Run man d2 for detailed docs."
if [ -n "${TALA-}" ]; then
log "Run man d2plugin-tala for detailed TALA docs."
@ -485,7 +485,7 @@ fetch_release_info() {
}
curl_gh() {
sh_c curl -fL ${GITHUB_TOKEN+"-H \"Authorization: Bearer \$GITHUB_TOKEN\""} "$@"
sh_c curl -fL ${GITHUB_TOKEN:+"-H \"Authorization: Bearer \$GITHUB_TOKEN\""} "$@"
}
fetch_gh() {

View file

@ -331,6 +331,28 @@ sudo -E apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-
sudo groupadd docker || true
sudo usermod -aG docker \$USER
printf %s '$CI_DOCKER_TOKEN' | docker login -u terrastruct --password-stdin
# For building images cross platform from the arm64 instance.
# We could use QEMU with:
# sudo -E apt-get install -y qemu qemu-user-static
# But we don't as playwright dependencies do not install on QEMU on either arm64 or amd64.
if [ "\$(uname -m)" = aarch64 ]; then
if [ "\$(stat -c '%a' ~/.ssh/id_ed25519 2>/dev/null)" != 600 ]; then
echo '$CI_TSTRUCT_ID_ED25519' >~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
fi
if ! docker context ls | grep -qF ci-d2-linux-amd64; then
docker context create ci-d2-linux-amd64 --docker "host=ssh://$CI_D2_LINUX_AMD64"
fi
if ! docker buildx ls | grep -qF 'd2 *'; then
docker buildx create --use --name d2 --platform linux/arm64 default
fi
if ! docker buildx inspect d2 | grep -qF ci-d2-linux-amd64; then
docker buildx create --append --name d2 --platform linux/amd64 ci-d2-linux-amd64
fi
fi
mkdir -p \$HOME/.local/bin
mkdir -p \$HOME/.local/share/man
EOF
@ -387,7 +409,7 @@ init_remote_env() {
sh_c ssh "$REMOTE_HOST" "sudo systemctl restart sshd"
# ubuntu has $PATH hard coded in /etc/environment for some reason. It takes precedence
# over ~/.ssh/environment.
sh_c ssh "$REMOTE_HOST" "sudo rm /etc/environment"
sh_c ssh "$REMOTE_HOST" "sudo rm -f /etc/environment"
fi
}

View file

@ -58,6 +58,9 @@ Flags:
images into the daemon for push later. It's not slow though to use --push-docker after
building the image as nearly all artifacts are cached.
Automatically set if called from release.sh
--latest-docker
Mark the built image with the latest tag. Automatically set if called from release.sh
EOF
}
@ -113,6 +116,10 @@ main() {
flag_noarg && shift "$FLAGSHIFT"
PUSH_DOCKER=1
;;
latest-docker)
flag_noarg && shift "$FLAGSHIFT"
LATEST_DOCKER=1
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
@ -149,7 +156,7 @@ main() {
runjob windows/arm64 'OS=windows ARCH=arm64 build' &
waitjobs
runjob linux/dockerimage 'OS=linux build_docker_image' &
runjob linux/docker build_docker &
runjob windows/amd64/msi 'OS=windows ARCH=amd64 build_windows_msi' &
waitjobs
}
@ -247,14 +254,27 @@ ARCHIVE=$ARCHIVE \
sh_c rsync --archive --human-readable "$REMOTE_HOST:src/d2/$ARCHIVE" "$ARCHIVE"
)}
build_docker_image() {
D2_DOCKER_IMAGE=${D2_DOCKER_IMAGE:-terrastruct/d2}
flags='--load'
if [ -n "${PUSH_DOCKER-}" -o -n "${RELEASE-}" ]; then
flags='--push --platform linux/amd64,linux/arm64'
build_docker() {
if [ -n "${LOCAL-}" ]; then
sh_c ./ci/release/docker/build.sh \
--version="$VERSION" \
${PUSH_DOCKER:+--push} \
${LATEST_DOCKER:+--latest}
return 0
fi
sh_c rsync --archive --human-readable ./ci/release/Dockerfile_entrypoint.sh "./ci/release/build/$VERSION"
sh_c docker buildx build $flags -t "$D2_DOCKER_IMAGE:$VERSION" -t "$D2_DOCKER_IMAGE:latest" --build-arg "VERSION=$VERSION" -f ./ci/release/Dockerfile "./ci/release/build/$VERSION"
sh_c lockfile_ssh "$CI_D2_LINUX_ARM64" .d2-build-lock
sh_c gitsync "$CI_D2_LINUX_ARM64" src/d2
sh_c rsync --archive --human-readable \
"$BUILD_DIR/d2-$VERSION"-linux-*.tar.gz \
"$CI_D2_LINUX_ARM64:src/d2/$BUILD_DIR/"
sh_c ssh "$CI_D2_LINUX_ARM64" \
"D2_DOCKER_IMAGE=${D2_DOCKER_IMAGE-}" \
"RELEASE=${RELEASE-}" \
./src/d2/ci/release/docker/build.sh \
--version="$VERSION" \
${PUSH_DOCKER:+--push} \
${LATEST_DOCKER:+--latest}
}
build_windows_msi() {

View file

@ -1,36 +1,5 @@
#### Features 🚀
- `double-border` keyword implemented. [#565](https://github.com/terrastruct/d2/pull/565)
- The [Dockerfile](./docs/INSTALL.md#docker) now supports rendering PNGs [#594](https://github.com/terrastruct/d2/issues/594)
- There was a minor breaking change as part of this where the default working directory of the Dockerfile is now `/home/debian/src` instead of `/root/src` to allow UID remapping with [`fixuid`](https://github.com/boxboat/fixuid).
- `d2 fmt` accepts multiple files to be formatted [#718](https://github.com/terrastruct/d2/issues/718)
- You can now use the reserved keywords `layers`/`scenarios`/`steps` to define diagrams
with multiple levels of abstractions. [#714](https://github.com/terrastruct/d2/pull/714)
Docs to come soon
- [#416](https://github.com/terrastruct/d2/issues/416) was also fixed so you can no
longer use keywords intended for use under `style` outside and vice versa. e.g.
`obj.style.shape` and `obj.double-border` are now illegal. The correct uses are
`obj.shape` and `obj.style.double-border`.
- Many other minor compiler bugs were fixed.
#### Improvements 🧹
- Code snippets use bold and italic font styles as determined by highlighter [#710](https://github.com/terrastruct/d2/issues/710), [#741](https://github.com/terrastruct/d2/issues/741)
- Reduces default padding of shapes. [#702](https://github.com/terrastruct/d2/pull/702)
- Ensures labels fit inside shapes with shape-specific inner bounding boxes. [#702](https://github.com/terrastruct/d2/pull/702)
- Improves package shape dimensions with short height. [#702](https://github.com/terrastruct/d2/pull/702)
- Keeps person shape from becoming too distorted. [#702](https://github.com/terrastruct/d2/pull/702)
- Ensures shapes with icons have enough padding for their labels. [#702](https://github.com/terrastruct/d2/pull/702)
- `--force-appendix` flag adds an appendix to SVG outputs with tooltips or links. [#761](https://github.com/terrastruct/d2/pull/761)
- `d2 themes` subcommand to list themes. [#760](https://github.com/terrastruct/d2/pull/760)
#### Bugfixes ⛑️
- Fixes groups overlapping in sequence diagrams when they end in a self loop. [#728](https://github.com/terrastruct/d2/pull/728)
- Fixes dimensions of unlabeled squares or circles with only a set width or height. [#702](https://github.com/terrastruct/d2/pull/702)
- Fixes scaling of actor shapes in sequence diagrams. [#702](https://github.com/terrastruct/d2/pull/702)
- Images can now be set to sizes smaller than 128x128. [#702](https://github.com/terrastruct/d2/pull/702)
- Fixes class height when there are no rows. [#756](https://github.com/terrastruct/d2/pull/756)

View file

@ -0,0 +1,56 @@
Here's what a D2 diagram looks like in 0.1 (left) vs 0.2 (right):
![before-after](https://user-images.githubusercontent.com/3120367/218556631-829047e5-e2f7-43e5-b98e-e81b4f76bdb2.jpg)
Much more legible, especially in larger diagrams! This upgrade trims a lot of the excess whitespace present before and makes diagrams more compact. We've also combed through each shape to improve their label and icon positions, paddings, and aspect ratios at different sizes. Example of icons and labels avoiding collisions:
<img width="509" alt="aws icons" src="https://user-images.githubusercontent.com/3120367/218557539-0e9ef284-363c-43d6-bc8d-157768a57aca.png">
We've also put up a hosted icon site for you to conveniently find common software architecture icons to include in your D2 diagrams. [https://icons.terrastruct.com](https://icons.terrastruct.com)
<img width="1380" alt="icons" src="https://user-images.githubusercontent.com/3120367/218560291-a9123142-5840-4fbe-95f7-78b1b539cc23.png">
There's also been a major compiler rewrite. It's fixed many minor compiler bugs, but most importantly, it implements multi-board diagrams. Stay tuned for more as we write docs and make this accessible in the next release!
#### Features 🚀
- `double-border` keyword implemented. [#565](https://github.com/terrastruct/d2/pull/565)
- The [Dockerfile](./docs/INSTALL.md#docker) now supports rendering PNGs [#594](https://github.com/terrastruct/d2/issues/594)
- There was a minor breaking change as part of this where the default working directory of the Dockerfile is now `/home/debian/src` instead of `/root/src` to allow UID remapping with [`fixuid`](https://github.com/boxboat/fixuid).
- `d2 fmt` accepts multiple files to be formatted [#718](https://github.com/terrastruct/d2/issues/718)
- `font-size` works for `sql_table` and `class` shapes [#769](https://github.com/terrastruct/d2/issues/769)
- You can now use the reserved keywords `layers`/`scenarios`/`steps` to define diagrams with multiple levels of abstractions. Coming soon. [#714](https://github.com/terrastruct/d2/pull/714)
#### Improvements 🧹
- Reduces default padding of shapes. [#702](https://github.com/terrastruct/d2/pull/702)
- Ensures labels fit inside shapes with shape-specific inner bounding boxes. [#702](https://github.com/terrastruct/d2/pull/702)
- dagre container labels changed positions to outside the shape. Many previously obscured container labels are now legible. [#788](https://github.com/terrastruct/d2/pull/788)
- Container icons are placed top-left instead of center, to ensure no collisions with children. [#806](https://github.com/terrastruct/d2/pull/806)
- Code snippets use bold and italic font styles as determined by highlighter [#710](https://github.com/terrastruct/d2/issues/710), [#741](https://github.com/terrastruct/d2/issues/741)
- Improves package shape dimensions with short height. [#702](https://github.com/terrastruct/d2/pull/702)
- Sequence diagrams are rendered more compacted, both vertically and horizontally. [#796](https://github.com/terrastruct/d2/pull/796)
- Keeps person shape from becoming too distorted. [#702](https://github.com/terrastruct/d2/pull/702)
- Keeps oval shape from becoming too thin. [#807](https://github.com/terrastruct/d2/pull/807)
- Ensures shapes with icons have enough padding for their labels. [#702](https://github.com/terrastruct/d2/pull/702)
- `--force-appendix` flag adds an appendix to SVG outputs with tooltips or links. [#761](https://github.com/terrastruct/d2/pull/761)
- `d2 themes` subcommand to list themes. [#760](https://github.com/terrastruct/d2/pull/760)
- `sql_table` header left-aligned with column [#769](https://github.com/terrastruct/d2/pull/769)
- Sequence diagram edge group labels are clearer [#782](https://github.com/terrastruct/d2/pull/782)
#### Bugfixes ⛑️
- Fixes groups overlapping in sequence diagrams when they end in a self loop. [#728](https://github.com/terrastruct/d2/pull/728)
- Fixes dimensions of unlabeled squares or circles with only a set width or height. [#702](https://github.com/terrastruct/d2/pull/702)
- Fixes scaling of actor shapes in sequence diagrams. [#702](https://github.com/terrastruct/d2/pull/702)
- Sequence diagram note ordering was sometimes wrong. [#796](https://github.com/terrastruct/d2/pull/796)
- Images can now be set to sizes smaller than 128x128. [#702](https://github.com/terrastruct/d2/pull/702)
- Tooltips with ampersand would result in invalid SVGs. [#798](https://github.com/terrastruct/d2/pull/798)
- Fixes class height when there are no rows. [#756](https://github.com/terrastruct/d2/pull/756)
- Border radius was not firefox-compatible. [#799](https://github.com/terrastruct/d2/pull/799)
#### Breaking changes
- You can no longer use keywords intended for use under `style` outside and vice versa. e.g. `obj.style.shape` and `obj.double-border` are now illegal. The correct usages have always been `obj.shape` and `obj.style.double-border`; it just wasn't enforced until now.

View file

@ -0,0 +1,42 @@
Dark mode support has landed! Thanks to https://github.com/vfosnar for such a substaintial first-time contribution to D2. Only one dark theme option accompanies the support, so if you have a dark theme you like, please feel free to submit into D2!
https://user-images.githubusercontent.com/3120367/221057628-e474b040-4ecb-4177-bb81-a04c95a4648f.mp4
D2 is now usable in non-Latin languages (and emojis!), as the font-measuring accounts for multi-byte characters. Thanks https://github.com/bo-ku-ra for keeping this top of mind.
D2 0.2.0 vs 0.2.1:
<img width="1200" alt="japanese" src="https://user-images.githubusercontent.com/3120367/221058010-9a405cbf-a1dc-4005-8820-bf17d920105c.png">
Sketch mode's subtle hand-drawn texture adapts to background colors. Previously the streaks were too subtle on lighter backgrounds and too prominent on darker ones.
<img width="399" alt="sketch" src="https://user-images.githubusercontent.com/3120367/221042548-aee58a6c-e0c0-4e58-8d79-d0b609a9d750.png">
This release also fixes a number of non-trivial layout bugs made in v0.2.0, and has better error messages.
#### Features 🚀
- Dark theme support! See [docs](https://d2lang.com/tour/themes#dark-theme). [#613](https://github.com/terrastruct/d2/pull/613)
- Many non-Latin languages (e.g. Chinese, Japanese, Korean) are usable now that multi-byte characters are measured correctly. [#817](https://github.com/terrastruct/d2/pull/817)
- Dimensions can be set on containers (layout engine dependent). [#845](https://github.com/terrastruct/d2/pull/845)
#### Improvements 🧹
- Sketch mode's subtle hand-drawn texture adapts to background colors. [#613](https://github.com/terrastruct/d2/pull/613)
- Improves label legibility for dagre containers by stopping container edges early if they would run into the label. [#880](https://github.com/terrastruct/d2/pull/880)
- Cleaner watch mode logs without timestamps. [#830](https://github.com/terrastruct/d2/pull/830)
- Remove duplicate success logs in watch mode. [#830](https://github.com/terrastruct/d2/pull/830)
- CLI reports when a feature is incompatible with layout engine, instead of silently ignoring. [#845](https://github.com/terrastruct/d2/pull/845)
- `near` key set to direct parent or ancestor throws an appropriate error message. [#851](https://github.com/terrastruct/d2/pull/851)
- Dimensions and positions are able to be set from API. [#853](https://github.com/terrastruct/d2/pull/853)
#### Bugfixes ⛑️
- Fixes edge case where layouts with dagre show a connection from the bottom side of shapes being slightly disconnected from the shape. [#820](https://github.com/terrastruct/d2/pull/820)
- Bounding boxes weren't accounting for icons placed on the boundaries. [#879](https://github.com/terrastruct/d2/pull/879)
- Sequence diagrams using special characters in object IDs could cause rendering bugs. [#856](https://github.com/terrastruct/d2/issues/856)
- Fixes rare compiler bug when using underscores in edges to create objects across containers. [#824](https://github.com/terrastruct/d2/pull/824)
- Fixes rare possibility of rendered connections being hidden or cut off. [#828](https://github.com/terrastruct/d2/pull/828)
- Creating nested children within `sql_table` and `class` shapes are now prevented (caused confusion when accidentally done). [#834](https://github.com/terrastruct/d2/pull/834)
- Fixes graph deserialization bug. [#837](https://github.com/terrastruct/d2/pull/837)
- `steps` with non-map fields could cause panics. [#783](https://github.com/terrastruct/d2/pull/783)

View file

@ -0,0 +1,32 @@
`style` keywords now apply at the root level, letting you style the diagram background and frame like so:
![chilly](https://user-images.githubusercontent.com/3120367/221755385-22e9078e-a8db-418d-81e4-282c8b33f1d7.svg)
[playground link](https://play.d2lang.com/?script=tFRdaxs7EH3fXzEP15cErry-GwJFlIC9cdJAXIL9kJeCUbSTtchacqVZ56P4v5eRd9d2k9LkofjFGo1mzplzdmIVCT8SAJ6mhBGaEhOAbT8J58o_jKp6F9pCkPApATjQRgL5GpNNkpChtmgcrIQvplxAhWuswNh7rwL5WlPtY1memATCJ0oAeMwSyK2ERkvoY98OI0AnpITT0wRgww3VY5BQB4EqkPg_Zh4-itTIKxtWyqOlGN2btIRtrQQgr1xd3HtnqX3NZpGwIFoFmaZ8Cn1C37Loa7dM1WNIvyI9Ov9gbNnLBv_2skHuLKGlXjY4x8qs0T-nw6V6cVbEJhfcZF6xH8Vd2Q_rsoG1h_wNlA1OgNvhxS7tvSBnqGtv6Pm_Xja4KtBS83-Ld7mqjLIa0-HtTNwOL16j27yeE4gzmCj_gGRsCcEQ8v2fIvwqn05iqemkJdKYYYFPqnR2N4_-SdH4q2k_O_moPDNyXpXYSjA7EZeV0gb9ryTfoMfdAikyGlQISIFTsLqTML4ewdG1UwXcqYpH54__gm3GlQpktOBGYhQbGVuK8fVo3jwXFV-1GN5BKaIf3lyBx-81ho4S343zjE_jPOt0OTRluxQ7TbbGeD_p-aV39Yo9yCnpOM-EsYEYvNDOkjL2NyyGNS1ghn5tdHSVR15ovH4mSGqNPiBMmxgcffuHf8cNG-bW5u-FWN1LJNA18U7HOIpc6QV-yGMvtcd0zh-RsgZDOuQA68mVetlgioUJvWxw411Ra9aXC-2--631zVKVnctbsPBZnIHmOiySjqgPBrFJkni-8W5tCvQhLsX-fk73ZpfzMwAA__8%3D&)
(also showcases a little 3d hexagon, newly supported thanks to our newest contributor @JettChenT !)
PDF is also now supported as an export format:
[demo.pdf](https://github.com/terrastruct/d2/files/10846644/demo.pdf)
#### Features 🚀
- PDF exports. See [docs](https://d2lang.com/tour/exports#pdf). [#120](https://github.com/terrastruct/d2/issues/120)
- Diagram background and frame can be added and styled. See [docs](https://d2lang.com/tour/style#root). [#910](https://github.com/terrastruct/d2/pull/910)
- `3d` works on `hexagon` shapes. [#869](https://github.com/terrastruct/d2/issues/869)
- The arm64 docker container supports rendering diagrams to PNGs. [#917](https://github.com/terrastruct/d2/pull/917)
#### Improvements 🧹
- `near` key set to sequence diagram children get an appropriate error message. [#899](https://github.com/terrastruct/d2/pull/899)
- `class` and `sql_table` shape respect `font-color` styling as header font color. [#899](https://github.com/terrastruct/d2/pull/899)
- SVG fits to screen by default in both watch mode and as a standalone SVG (this time with just CSS, no JS). [#725](https://github.com/terrastruct/d2/pull/725)
- Only chromium is installed when rendering png diagrams instead of also installing webkit and firefox. [#835](https://github.com/terrastruct/d2/issues/835)
- Multiboard output is now self-contained and less confusing. See [#923](https://github.com/terrastruct/d2/pull/923)
#### Bugfixes ⛑️
- Error reported when no actors are declared in sequence diagram. [#886](https://github.com/terrastruct/d2/pull/886)
- Fixes img bundling on image shapes. [#889](https://github.com/terrastruct/d2/issues/889)
- `class` shape as sequence diagram actors had wrong colors. [#899](https://github.com/terrastruct/d2/pull/899)
- Fixes regression in last release where some hex codes were not working. [#922](https://github.com/terrastruct/d2/pull/922)

View file

@ -7,9 +7,8 @@ RUN apt-get update && apt-get install -y ca-certificates curl dumb-init sudo
RUN curl -fsSL https://deb.nodesource.com/setup_19.x | bash -s - && \
apt-get install -y nodejs
# https://github.com/microsoft/playwright/issues/18319
# Hopefully soon.
RUN if [ "$TARGETARCH" = amd64 ]; then npx playwright install-deps; fi
# See https://github.com/microsoft/playwright/issues/18319
RUN npx playwright@1.31.1 install-deps chromium
RUN adduser --gecos '' --disabled-password debian \
&& echo "debian ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/nopasswd
@ -21,14 +20,14 @@ RUN curl -fsSL "https://github.com/boxboat/fixuid/releases/download/v0.5/fixuid-
&& printf "user: debian\ngroup: debian\npaths: [/home/debian]\n" > /etc/fixuid/config.yml
COPY ./d2-*-linux-$TARGETARCH.tar.gz /tmp
ADD ./Dockerfile_entrypoint.sh /usr/local/bin/entrypoint.sh
ADD ./entrypoint.sh /usr/local/bin/entrypoint.sh
RUN mkdir -p /usr/local/lib/d2 \
&& tar -C /usr/local/lib/d2 -xzf /tmp/d2-*-linux-"$TARGETARCH".tar.gz \
&& /usr/local/lib/d2/d2-*/scripts/install.sh \
&& rm -Rf /tmp/d2-*-linux-"$TARGETARCH".tar.gz
USER debian:debian
RUN if [ "$TARGETARCH" = amd64 ]; then d2 init-playwright; fi
RUN d2 init-playwright
WORKDIR /home/debian/src
EXPOSE 8080

64
ci/release/docker/build.sh Executable file
View file

@ -0,0 +1,64 @@
#!/bin/sh
set -eu
. "$(dirname "$0")/../../../ci/sub/lib.sh"
cd -- "$(dirname "$0")/../../.."
help() {
cat <<EOF
usage: $0 [-p|--push] [--latest] [--version=str]
EOF
}
main() {
while flag_parse "$@"; do
case "$FLAG" in
h|help)
help
return 0
;;
p|push)
flag_noarg && shift "$FLAGSHIFT"
PUSH=1
;;
latest)
flag_noarg && shift "$FLAGSHIFT"
LATEST=1
;;
version)
flag_reqarg && shift "$FLAGSHIFT"
VERSION=$FLAGARG
;;
*)
flag_errusage "unrecognized flag $FLAGRAW"
;;
esac
done
shift "$FLAGSHIFT"
if [ -z "${VERSION-}" ]; then
VERSION=$(readlink ./ci/release/build/latest)
fi
D2_DOCKER_IMAGE=${D2_DOCKER_IMAGE:-terrastruct/d2}
sh_c mkdir -p "./ci/release/build/$VERSION/docker"
sh_c cp \
"./ci/release/build/$VERSION/d2-$VERSION"-linux-*.tar.gz \
"./ci/release/build/$VERSION/docker/"
sh_c cp \
./ci/release/docker/entrypoint.sh \
"./ci/release/build/$VERSION/docker/entrypoint.sh"
flags='--load'
if [ -n "${PUSH-}" -o -n "${RELEASE-}" ]; then
flags='--push --platform linux/amd64,linux/arm64'
fi
if [ -n "${LATEST-}" -o -n "${RELEASE-}" ]; then
flags="$flags -t $D2_DOCKER_IMAGE:latest"
fi
sh_c docker buildx build $flags \
-t "$D2_DOCKER_IMAGE:$VERSION" \
-f ./ci/release/docker/Dockerfile "./ci/release/build/$VERSION/docker"
}
main "$@"

View file

@ -66,6 +66,14 @@ Port listening address when used with
.It Fl t , -theme Ar 0
Set the diagram theme ID
.Ns .
.It Fl -dark-theme Ar -1
The theme to use when the viewer's browser is in dark mode. When left unset
.Fl -theme
is used for both light and dark mode. Be aware that explicit styles set in D2 code will
still be applied and this may produce unexpected results. We plan on resolving this by
making style maps in D2 light/dark mode specific. See
.Lk https://github.com/terrastruct/d2/issues/831
.Ns .
.It Fl s , -sketch Ar false
Renders the diagram to look like it was sketched by hand
.Ns .

2
ci/sub

@ -1 +1 @@
Subproject commit 2009cdd523e00cc2e9b8ba804095f71ca70d5671
Subproject commit 5198280010adc30aabb611579579916abfe20a45

View file

@ -593,8 +593,11 @@ type Key struct {
Value ValueBox `json:"value"`
}
// TODO there's more stuff to compare
// TODO maybe need to compare Primary
func (mk1 *Key) Equals(mk2 *Key) bool {
if mk1.Ampersand != mk2.Ampersand {
return false
}
if (mk1.Key == nil) != (mk2.Key == nil) {
return false
}
@ -624,6 +627,16 @@ func (mk1 *Key) Equals(mk2 *Key) bool {
}
}
}
if mk1.EdgeKey != nil {
if len(mk1.EdgeKey.Path) != len(mk2.EdgeKey.Path) {
return false
}
for i, id := range mk1.EdgeKey.Path {
if id.Unbox().ScalarString() != mk2.EdgeKey.Path[i].Unbox().ScalarString() {
return false
}
}
}
if mk1.Value.Map != nil {
if len(mk1.Value.Map.Nodes) != len(mk2.Value.Map.Nodes) {

View file

@ -15,6 +15,8 @@ import (
"oss.terrastruct.com/d2/d2target"
)
const complexIDs = false
func GenDSL(maxi int) (_ string, err error) {
gs := &dslGenState{
rand: mathrand.New(mathrand.NewSource(time.Now().UnixNano())),
@ -62,7 +64,11 @@ func (gs *dslGenState) gen(maxi int) error {
}
func (gs *dslGenState) genNode(containerID string) (string, error) {
nodeID := gs.randStr(32, true)
maxLen := 8
if complexIDs {
maxLen = 32
}
nodeID := gs.randStr(maxLen, true)
if containerID != "" {
nodeID = containerID + "." + nodeID
}
@ -95,7 +101,11 @@ func (gs *dslGenState) node() error {
if gs.roll(25, 75) == 0 {
// 25% chance of adding a label.
gs.g, err = d2oracle.Set(gs.g, nodeID, nil, go2.Pointer(gs.randStr(256, false)))
maxLen := 8
if complexIDs {
maxLen = 256
}
gs.g, err = d2oracle.Set(gs.g, nodeID, nil, go2.Pointer(gs.randStr(maxLen, false)))
if err != nil {
return err
}
@ -154,7 +164,11 @@ func (gs *dslGenState) edge() error {
return err
}
if gs.randBool() {
gs.g, err = d2oracle.Set(gs.g, key, nil, go2.Pointer(gs.randStr(128, false)))
maxLen := 8
if complexIDs {
maxLen = 128
}
gs.g, err = d2oracle.Set(gs.g, key, nil, go2.Pointer(gs.randStr(maxLen, false)))
if err != nil {
return err
}
@ -191,11 +205,15 @@ func (gs *dslGenState) randBool() bool {
// TODO go back to using xrand.String, currently some incompatibility with
// stuffing these strings into a script for dagre
func randRune() rune {
if mathrand.Int31n(100) == 0 {
// Generate newline 1% of the time.
return '\n'
if complexIDs {
if mathrand.Int31n(100) == 0 {
// Generate newline 1% of the time.
return '\n'
}
return mathrand.Int31n(128) + 1
} else {
return mathrand.Int31n(26) + 97
}
return mathrand.Int31n(128) + 1
}
func (gs *dslGenState) findOuterSequenceDiagram(nodeID string) string {

View file

@ -128,7 +128,7 @@ func test(t *testing.T, textPath, text string) {
t.Fatal(err)
}
_, err = d2exporter.Export(ctx, g, 0, nil)
_, err = d2exporter.Export(ctx, g, nil)
if err != nil {
t.Fatal(err)
}
@ -169,6 +169,10 @@ func testPinned(t *testing.T, outDir string) {
name: "orientation",
text: "a: {\n b\n c\n }\n a <- a.c\n a.b -> a\n",
},
{
name: "cannot create edge between boards",
text: `"" <-> ""`,
},
}
for _, tc := range testCases {

View file

@ -1,4 +1,4 @@
package main
package d2cli
import (
"bytes"

View file

@ -1,4 +1,4 @@
package main
package d2cli
import (
"context"

562
d2cli/main.go Normal file
View file

@ -0,0 +1,562 @@
package d2cli
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/playwright-community/playwright-go"
"github.com/spf13/pflag"
"go.uber.org/multierr"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/util-go/xmain"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2plugin"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2renderers/d2svg/appendix"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/imgbundler"
ctxlog "oss.terrastruct.com/d2/lib/log"
pdflib "oss.terrastruct.com/d2/lib/pdf"
"oss.terrastruct.com/d2/lib/png"
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/d2/lib/version"
"cdr.dev/slog"
"cdr.dev/slog/sloggers/sloghuman"
)
func Run(ctx context.Context, ms *xmain.State) (err error) {
// :(
ctx = DiscardSlog(ctx)
// These should be kept up-to-date with the d2 man page
watchFlag, err := ms.Opts.Bool("D2_WATCH", "watch", "w", false, "watch for changes to input and live reload. Use $HOST and $PORT to specify the listening address.\n(default localhost:0, which is will open on a randomly available local port).")
if err != nil {
return err
}
hostFlag := ms.Opts.String("HOST", "host", "h", "localhost", "host listening address when used with watch")
portFlag := ms.Opts.String("PORT", "port", "p", "0", "port listening address when used with watch")
bundleFlag, err := ms.Opts.Bool("D2_BUNDLE", "bundle", "b", true, "when outputting SVG, bundle all assets and layers into the output file")
if err != nil {
return err
}
forceAppendixFlag, err := ms.Opts.Bool("D2_FORCE_APPENDIX", "force-appendix", "", false, "an appendix for tooltips and links is added to PNG exports since they are not interactive. --force-appendix adds an appendix to SVG exports as well")
if err != nil {
return err
}
debugFlag, err := ms.Opts.Bool("DEBUG", "debug", "d", false, "print debug logs.")
if err != nil {
return err
}
layoutFlag := ms.Opts.String("D2_LAYOUT", "layout", "l", "dagre", `the layout engine used`)
themeFlag, err := ms.Opts.Int64("D2_THEME", "theme", "t", 0, "the diagram theme ID")
if err != nil {
return err
}
darkThemeFlag, err := ms.Opts.Int64("D2_DARK_THEME", "dark-theme", "", -1, "The theme to use when the viewer's browser is in dark mode. When left unset -theme is used for both light and dark mode. Be aware that explicit styles set in D2 code will still be applied and this may produce unexpected results. We plan on resolving this by making style maps in D2 light/dark mode specific. See https://github.com/terrastruct/d2/issues/831.")
if err != nil {
return err
}
padFlag, err := ms.Opts.Int64("D2_PAD", "pad", "", d2svg.DEFAULT_PADDING, "pixels padded around the rendered diagram")
if err != nil {
return err
}
versionFlag, err := ms.Opts.Bool("", "version", "v", false, "get the version")
if err != nil {
return err
}
sketchFlag, err := ms.Opts.Bool("D2_SKETCH", "sketch", "s", false, "render the diagram to look like it was sketched by hand")
if err != nil {
return err
}
ps, err := d2plugin.ListPlugins(ctx)
if err != nil {
return err
}
err = populateLayoutOpts(ctx, ms, ps)
if err != nil {
return err
}
err = ms.Opts.Flags.Parse(ms.Opts.Args)
if !errors.Is(err, pflag.ErrHelp) && err != nil {
return xmain.UsageErrorf("failed to parse flags: %v", err)
}
if errors.Is(err, pflag.ErrHelp) {
help(ms)
return nil
}
if len(ms.Opts.Flags.Args()) > 0 {
switch ms.Opts.Flags.Arg(0) {
case "init-playwright":
return initPlaywright()
case "layout":
return layoutCmd(ctx, ms, ps)
case "themes":
themesCmd(ctx, ms)
return nil
case "fmt":
return fmtCmd(ctx, ms)
case "version":
if len(ms.Opts.Flags.Args()) > 1 {
return xmain.UsageErrorf("version subcommand accepts no arguments")
}
fmt.Println(version.Version)
return nil
}
}
if *debugFlag {
ms.Env.Setenv("DEBUG", "1")
}
var inputPath string
var outputPath string
if len(ms.Opts.Flags.Args()) == 0 {
if versionFlag != nil && *versionFlag {
fmt.Println(version.Version)
return nil
}
help(ms)
return nil
} else if len(ms.Opts.Flags.Args()) >= 3 {
return xmain.UsageErrorf("too many arguments passed")
}
if len(ms.Opts.Flags.Args()) >= 1 {
inputPath = ms.Opts.Flags.Arg(0)
}
if len(ms.Opts.Flags.Args()) >= 2 {
outputPath = ms.Opts.Flags.Arg(1)
} else {
if inputPath == "-" {
outputPath = "-"
} else {
outputPath = renameExt(inputPath, ".svg")
}
}
match := d2themescatalog.Find(*themeFlag)
if match == (d2themes.Theme{}) {
return xmain.UsageErrorf("-t[heme] could not be found. The available options are:\n%s\nYou provided: %d", d2themescatalog.CLIString(), *themeFlag)
}
ms.Log.Debug.Printf("using theme %s (ID: %d)", match.Name, *themeFlag)
if *darkThemeFlag == -1 {
darkThemeFlag = nil // TODO this is a temporary solution: https://github.com/terrastruct/util-go/issues/7
}
if darkThemeFlag != nil {
match = d2themescatalog.Find(*darkThemeFlag)
if match == (d2themes.Theme{}) {
return xmain.UsageErrorf("--dark-theme could not be found. The available options are:\n%s\nYou provided: %d", d2themescatalog.CLIString(), *darkThemeFlag)
}
ms.Log.Debug.Printf("using dark theme %s (ID: %d)", match.Name, *darkThemeFlag)
}
plugin, err := d2plugin.FindPlugin(ctx, ps, *layoutFlag)
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
return layoutNotFound(ctx, ps, *layoutFlag)
}
return err
}
err = d2plugin.HydratePluginOpts(ctx, ms, plugin)
if err != nil {
return err
}
pinfo, err := plugin.Info(ctx)
if err != nil {
return err
}
plocation := pinfo.Type
if pinfo.Type == "binary" {
plocation = fmt.Sprintf("executable plugin at %s", humanPath(pinfo.Path))
}
ms.Log.Debug.Printf("using layout plugin %s (%s)", *layoutFlag, plocation)
var pw png.Playwright
if filepath.Ext(outputPath) == ".png" || filepath.Ext(outputPath) == ".pdf" {
if darkThemeFlag != nil {
ms.Log.Warn.Printf("--dark-theme cannot be used while exporting to another format other than .svg")
darkThemeFlag = nil
}
pw, err = png.InitPlaywright()
if err != nil {
return err
}
defer func() {
cleanupErr := pw.Cleanup()
if err == nil {
err = cleanupErr
}
}()
}
if *watchFlag {
if inputPath == "-" {
return xmain.UsageErrorf("-w[atch] cannot be combined with reading input from stdin")
}
w, err := newWatcher(ctx, ms, watcherOpts{
layoutPlugin: plugin,
sketch: *sketchFlag,
themeID: *themeFlag,
darkThemeID: darkThemeFlag,
pad: *padFlag,
host: *hostFlag,
port: *portFlag,
inputPath: inputPath,
outputPath: outputPath,
bundle: *bundleFlag,
forceAppendix: *forceAppendixFlag,
pw: pw,
})
if err != nil {
return err
}
return w.run()
}
ctx, cancel := context.WithTimeout(ctx, time.Minute*2)
defer cancel()
_, written, err := compile(ctx, ms, plugin, *sketchFlag, *padFlag, *themeFlag, darkThemeFlag, inputPath, outputPath, *bundleFlag, *forceAppendixFlag, pw.Page)
if err != nil {
if written {
return fmt.Errorf("failed to fully compile (partial render written): %w", err)
}
return fmt.Errorf("failed to compile: %w", err)
}
return nil
}
func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch bool, pad, themeID int64, darkThemeID *int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page) (_ []byte, written bool, _ error) {
start := time.Now()
input, err := ms.ReadPath(inputPath)
if err != nil {
return nil, false, err
}
ruler, err := textmeasure.NewRuler()
if err != nil {
return nil, false, err
}
layout := plugin.Layout
opts := &d2lib.CompileOptions{
Layout: layout,
Ruler: ruler,
}
if sketch {
opts.FontFamily = go2.Pointer(d2fonts.HandDrawn)
}
diagram, g, err := d2lib.Compile(ctx, string(input), opts)
if err != nil {
return nil, false, err
}
pluginInfo, err := plugin.Info(ctx)
if err != nil {
return nil, false, err
}
err = d2plugin.FeatureSupportCheck(pluginInfo, g)
if err != nil {
return nil, false, err
}
var svg []byte
if filepath.Ext(outputPath) == ".pdf" {
svg, err = renderPDF(ctx, ms, plugin, sketch, pad, themeID, outputPath, page, ruler, diagram, nil, nil)
} else {
compileDur := time.Since(start)
svg, err = render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, outputPath, bundle, forceAppendix, page, ruler, diagram)
}
if err != nil {
return svg, false, err
}
if filepath.Ext(outputPath) == ".pdf" {
dur := time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", inputPath, outputPath, dur)
}
return svg, true, nil
}
func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plugin d2plugin.Plugin, sketch bool, pad int64, themeID int64, darkThemeID *int64, inputPath, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
if diagram.Name != "" {
ext := filepath.Ext(outputPath)
outputPath = strings.TrimSuffix(outputPath, ext)
outputPath = filepath.Join(outputPath, diagram.Name)
outputPath += ext
}
boardOutputPath := outputPath
if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 {
// Boards with subboards must be self-contained folders.
ext := filepath.Ext(boardOutputPath)
boardOutputPath = strings.TrimSuffix(boardOutputPath, ext)
os.RemoveAll(boardOutputPath)
boardOutputPath = filepath.Join(boardOutputPath, "index")
boardOutputPath += ext
}
layersOutputPath := outputPath
if len(diagram.Scenarios) > 0 || len(diagram.Steps) > 0 {
ext := filepath.Ext(layersOutputPath)
layersOutputPath = strings.TrimSuffix(layersOutputPath, ext)
layersOutputPath = filepath.Join(layersOutputPath, "layers")
layersOutputPath += ext
}
scenariosOutputPath := outputPath
if len(diagram.Layers) > 0 || len(diagram.Steps) > 0 {
ext := filepath.Ext(scenariosOutputPath)
scenariosOutputPath = strings.TrimSuffix(scenariosOutputPath, ext)
scenariosOutputPath = filepath.Join(scenariosOutputPath, "scenarios")
scenariosOutputPath += ext
}
stepsOutputPath := outputPath
if len(diagram.Layers) > 0 || len(diagram.Scenarios) > 0 {
ext := filepath.Ext(stepsOutputPath)
stepsOutputPath = strings.TrimSuffix(stepsOutputPath, ext)
stepsOutputPath = filepath.Join(stepsOutputPath, "steps")
stepsOutputPath += ext
}
for _, dl := range diagram.Layers {
_, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, layersOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil {
return nil, err
}
}
for _, dl := range diagram.Scenarios {
_, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, scenariosOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil {
return nil, err
}
}
for _, dl := range diagram.Steps {
_, err := render(ctx, ms, compileDur, plugin, sketch, pad, themeID, darkThemeID, inputPath, stepsOutputPath, bundle, forceAppendix, page, ruler, dl)
if err != nil {
return nil, err
}
}
if !diagram.IsFolderOnly {
start := time.Now()
svg, err := _render(ctx, ms, plugin, sketch, pad, themeID, darkThemeID, boardOutputPath, bundle, forceAppendix, page, ruler, diagram)
if err != nil {
return svg, err
}
dur := compileDur + time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", inputPath, boardOutputPath, dur)
return svg, nil
}
return nil, nil
}
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch bool, pad int64, themeID int64, darkThemeID *int64, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
toPNG := filepath.Ext(outputPath) == ".png"
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad),
Sketch: sketch,
ThemeID: themeID,
DarkThemeID: darkThemeID,
SetDimensions: toPNG,
})
if err != nil {
return nil, err
}
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return svg, err
}
svg, bundleErr := imgbundler.BundleLocal(ctx, ms, svg)
if bundle {
var bundleErr2 error
svg, bundleErr2 = imgbundler.BundleRemote(ctx, ms, svg)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
}
if forceAppendix && !toPNG {
svg = appendix.Append(diagram, ruler, svg)
}
out := svg
if toPNG {
svg := appendix.Append(diagram, ruler, svg)
if !bundle {
var bundleErr2 error
svg, bundleErr2 = imgbundler.BundleRemote(ctx, ms, svg)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
}
out, err = png.ConvertSVG(ms, page, svg)
if err != nil {
return svg, err
}
} else {
if len(out) > 0 && out[len(out)-1] != '\n' {
out = append(out, '\n')
}
}
err = os.MkdirAll(filepath.Dir(outputPath), 0755)
if err != nil {
return svg, err
}
err = ms.WritePath(outputPath, out)
if err != nil {
return svg, err
}
if bundleErr != nil {
return svg, bundleErr
}
return svg, nil
}
func renderPDF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, sketch bool, pad, themeID int64, outputPath string, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram, pdf *pdflib.GoFPDF, boardPath []string) (svg []byte, err error) {
var isRoot bool
if pdf == nil {
pdf = pdflib.Init()
isRoot = true
}
var currBoardPath []string
// Root board doesn't have a name, so we use the output filename
if diagram.Name == "" {
ext := filepath.Ext(outputPath)
trimmedPath := strings.TrimSuffix(outputPath, ext)
splitPath := strings.Split(trimmedPath, "/")
rootName := splitPath[len(splitPath)-1]
currBoardPath = append(boardPath, rootName)
} else {
currBoardPath = append(boardPath, diagram.Name)
}
if !diagram.IsFolderOnly {
rootFill := diagram.Root.Fill
// gofpdf will print the png img with a slight filter
// make the bg fill within the png transparent so that the pdf bg fill is the only bg color present
diagram.Root.Fill = "transparent"
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: int(pad),
Sketch: sketch,
SetDimensions: true,
})
if err != nil {
return nil, err
}
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return svg, err
}
svg, bundleErr := imgbundler.BundleLocal(ctx, ms, svg)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, ms, svg)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return svg, bundleErr
}
svg = appendix.Append(diagram, ruler, svg)
pngImg, err := png.ConvertSVG(ms, page, svg)
if err != nil {
return svg, err
}
err = pdf.AddPDFPage(pngImg, currBoardPath, themeID, rootFill)
if err != nil {
return svg, err
}
}
for _, dl := range diagram.Layers {
_, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath)
if err != nil {
return nil, err
}
}
for _, dl := range diagram.Scenarios {
_, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath)
if err != nil {
return nil, err
}
}
for _, dl := range diagram.Steps {
_, err := renderPDF(ctx, ms, plugin, sketch, pad, themeID, "", page, ruler, dl, pdf, currBoardPath)
if err != nil {
return nil, err
}
}
if isRoot {
err := pdf.Export(outputPath)
if err != nil {
return nil, err
}
}
return svg, nil
}
// newExt must include leading .
func renameExt(fp string, newExt string) string {
ext := filepath.Ext(fp)
if ext == "" {
return fp + newExt
} else {
return strings.TrimSuffix(fp, ext) + newExt
}
}
// TODO: remove after removing slog
func DiscardSlog(ctx context.Context) context.Context {
return ctxlog.With(ctx, slog.Make(sloghuman.Sink(io.Discard)))
}
func populateLayoutOpts(ctx context.Context, ms *xmain.State, ps []d2plugin.Plugin) error {
pluginFlags, err := d2plugin.ListPluginFlags(ctx, ps)
if err != nil {
return err
}
for _, f := range pluginFlags {
f.AddToOpts(ms.Opts)
// Don't pollute the main d2 flagset with these. It'll be a lot
ms.Opts.Flags.MarkHidden(f.Name)
}
return nil
}
func initPlaywright() error {
pw, err := png.InitPlaywright()
if err != nil {
return err
}
return pw.Cleanup()
}

View file

@ -1,4 +1,4 @@
package main_test
package d2cli_test
import "testing"

View file

@ -1,3 +1,13 @@
body {
margin: 0;
}
#d2-svg-container > svg {
position: absolute;
width: 100%;
height: 100%;
}
#d2-err {
/* This style was copied from Chrome's svg parser error style. */
white-space: pre-wrap;

View file

@ -11,8 +11,6 @@ function init(reconnectDelay) {
const ws = new WebSocket(
`ws://${window.location.host}${window.location.pathname}watch`
);
let isInit = true;
let ratio;
ws.onopen = () => {
reconnectDelay = 1000;
console.info("watch websocket opened");
@ -33,27 +31,6 @@ function init(reconnectDelay) {
// setting innerHTML to only the actual svg innards. However then you also need to parse
// out the width, height and viewbox out of the top level SVG tag and update those manually.
d2SVG.innerHTML = msg.svg;
const svgEl = d2SVG.querySelector("#d2-svg");
let width = parseInt(svgEl.getAttribute("width"), 10);
let height = parseInt(svgEl.getAttribute("height"), 10);
if (isInit) {
if (width > height) {
if (width > window.innerWidth) {
ratio = window.innerWidth / width;
}
} else if (height > window.innerHeight) {
ratio = window.innerHeight / height;
}
// Scale svg fit to zoom
isInit = false;
}
if (ratio) {
// body padding is 8px
svgEl.setAttribute("width", width * ratio - 16);
svgEl.setAttribute("height", height * ratio - 16);
}
d2ErrDiv.style.display = "none";
}
if (msg.err) {

View file

@ -1,4 +1,4 @@
package main
package d2cli
import (
"context"
@ -41,6 +41,7 @@ var staticFS embed.FS
type watcherOpts struct {
layoutPlugin d2plugin.Plugin
themeID int64
darkThemeID *int64
pad int64
sketch bool
host string
@ -344,7 +345,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
recompiledPrefix = "re"
}
if filepath.Ext(w.outputPath) == ".png" && !w.pw.Browser.IsConnected() {
if (filepath.Ext(w.outputPath) == ".png" || filepath.Ext(w.outputPath) == ".pdf") && !w.pw.Browser.IsConnected() {
newPW, err := w.pw.RestartBrowser()
if err != nil {
broadcastErr := fmt.Errorf("issue encountered with PNG exporter: %w", err)
@ -357,7 +358,7 @@ func (w *watcher) compileLoop(ctx context.Context) error {
w.pw = newPW
}
svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.sketch, w.pad, w.themeID, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page)
svg, _, err := compile(ctx, w.ms, w.layoutPlugin, w.sketch, w.pad, w.themeID, w.darkThemeID, w.inputPath, w.outputPath, w.bundle, w.forceAppendix, w.pw.Page)
errs := ""
if err != nil {
if len(svg) > 0 {
@ -367,8 +368,6 @@ func (w *watcher) compileLoop(ctx context.Context) error {
}
errs = err.Error()
w.ms.Log.Error.Print(errs)
} else {
w.ms.Log.Success.Printf("successfully %scompiled %v to %v", recompiledPrefix, w.inputPath, w.outputPath)
}
w.broadcast(&compileResult{
SVG: string(svg),

View file

@ -1,7 +1,7 @@
//go:build dev
// +build dev
package main
package d2cli
func init() {
devMode = true

View file

@ -72,8 +72,12 @@ func (c *compiler) compileBoard(g *d2graph.Graph, ir *d2ir.Map) *d2graph.Graph {
c.compileBoardsField(g, ir, "scenarios")
c.compileBoardsField(g, ir, "steps")
if d2ir.ParentMap(ir).CopyBase(nil).Equal(ir.CopyBase(nil)) {
if len(g.Layers) > 0 || len(g.Scenarios) > 0 || len(g.Steps) > 0 {
g.IsFolderOnly = true
}
}
c.validateBoardLink(g, ir)
return g
}
@ -163,6 +167,17 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
return
}
if obj.Parent != nil {
if obj.Parent.Attributes.Shape.Value == d2target.ShapeSQLTable {
c.errorf(f.LastRef().AST(), "sql_table columns cannot have children")
return
}
if obj.Parent.Attributes.Shape.Value == d2target.ShapeClass {
c.errorf(f.LastRef().AST(), "class fields cannot have children")
return
}
}
obj = obj.EnsureChild(d2graphIDA([]string{f.Name}))
if f.Primary() != nil {
c.compileLabel(obj.Attributes, f)
@ -181,7 +196,7 @@ func (c *compiler) compileField(obj *d2graph.Object, f *d2ir.Field) {
}
}
scopeObjIDA := d2ir.IDA(fr.Context.ScopeMap)
scopeObj, _ := obj.Graph.Root.HasChild(scopeObjIDA)
scopeObj := obj.Graph.Root.EnsureChildIDVal(scopeObjIDA)
obj.References = append(obj.References, d2graph.Reference{
Key: fr.KeyPath,
KeyPathIndex: fr.KeyPathIndex(),
@ -258,7 +273,9 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
nearKey.Range = scalar.GetRange()
attrs.NearKey = nearKey
case "tooltip":
attrs.Tooltip = scalar.ScalarString()
attrs.Tooltip = &d2graph.Scalar{}
attrs.Tooltip.Value = scalar.ScalarString()
attrs.Tooltip.MapKey = f.LastPrimaryKey()
case "width":
_, err := strconv.Atoi(scalar.ScalarString())
if err != nil {
@ -277,10 +294,36 @@ func (c *compiler) compileReserved(attrs *d2graph.Attributes, f *d2ir.Field) {
attrs.Height = &d2graph.Scalar{}
attrs.Height.Value = scalar.ScalarString()
attrs.Height.MapKey = f.LastPrimaryKey()
case "top":
v, err := strconv.Atoi(scalar.ScalarString())
if err != nil {
c.errorf(scalar, "non-integer top %#v: %s", scalar.ScalarString(), err)
return
}
if v < 0 {
c.errorf(scalar, "top must be a non-negative integer: %#v", scalar.ScalarString())
return
}
attrs.Top = &d2graph.Scalar{}
attrs.Top.Value = scalar.ScalarString()
attrs.Top.MapKey = f.LastPrimaryKey()
case "left":
v, err := strconv.Atoi(scalar.ScalarString())
if err != nil {
c.errorf(scalar, "non-integer left %#v: %s", scalar.ScalarString(), err)
return
}
if v < 0 {
c.errorf(scalar, "left must be a non-negative integer: %#v", scalar.ScalarString())
return
}
attrs.Left = &d2graph.Scalar{}
attrs.Left.Value = scalar.ScalarString()
attrs.Left.MapKey = f.LastPrimaryKey()
case "link":
attrs.Link = &d2graph.Scalar{}
attrs.Link.Value = scalar.ScalarString()
attrs.Link.MapKey = f.LastPrimaryKey()
attrs.Link.MapKey.Range = scalar.GetRange()
case "direction":
dirs := []string{"up", "down", "right", "left"}
if !go2.Contains(dirs, scalar.ScalarString()) {
@ -358,6 +401,10 @@ func compileStyleFieldInit(attrs *d2graph.Attributes, f *d2ir.Field) {
attrs.Width = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
case "height":
attrs.Height = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
case "top":
attrs.Top = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
case "left":
attrs.Left = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
case "double-border":
attrs.Style.DoubleBorder = &d2graph.Scalar{MapKey: f.LastPrimaryKey()}
}
@ -387,7 +434,7 @@ func (c *compiler) compileEdge(obj *d2graph.Object, e *d2ir.Edge) {
edge.Attributes.Label.MapKey = e.LastPrimaryKey()
for _, er := range e.References {
scopeObjIDA := d2ir.IDA(er.Context.ScopeMap)
scopeObj, _ := edge.Src.Graph.Root.HasChild(d2graphIDA(scopeObjIDA))
scopeObj := edge.Src.Graph.Root.EnsureChildIDVal(scopeObjIDA)
edge.References = append(edge.References, d2graph.EdgeReference{
Edge: er.Context.Edge,
MapKey: er.Context.Key,
@ -564,14 +611,6 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
keyword := strings.ToLower(f.Name)
_, isReserved := d2graph.ReservedKeywords[keyword]
if isReserved {
switch obj.Attributes.Shape.Value {
case d2target.ShapeSQLTable, d2target.ShapeClass:
default:
if len(obj.Children) > 0 && (f.Name == "width" || f.Name == "height") {
c.errorf(f.LastPrimaryKey(), fmt.Sprintf("%s cannot be used on container: %s", f.Name, obj.AbsID()))
}
}
switch obj.Attributes.Shape.Value {
case d2target.ShapeCircle, d2target.ShapeSquare:
checkEqual := (keyword == "width" && obj.Attributes.Height != nil) || (keyword == "height" && obj.Attributes.Width != nil)
@ -583,8 +622,8 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
switch f.Name {
case "style":
if obj.Attributes.Style.ThreeDee != nil {
if !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeRectangle) {
c.errorf(obj.Attributes.Style.ThreeDee.MapKey, `key "3d" can only be applied to squares and rectangles`)
if !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeSquare) && !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeRectangle) && !strings.EqualFold(obj.Attributes.Shape.Value, d2target.ShapeHexagon) {
c.errorf(obj.Attributes.Style.ThreeDee.MapKey, `key "3d" can only be applied to squares, rectangles, and hexagons`)
}
}
if obj.Attributes.Style.DoubleBorder != nil {
@ -620,21 +659,37 @@ func (c *compiler) validateKey(obj *d2graph.Object, f *d2ir.Field) {
func (c *compiler) validateNear(g *d2graph.Graph) {
for _, obj := range g.Objects {
if obj.Attributes.NearKey != nil {
_, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
nearObj, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
_, isConst := d2graph.NearConstants[d2graph.Key(obj.Attributes.NearKey)[0]]
if !isKey && !isConst {
c.errorf(obj.Attributes.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
continue
}
if !isKey && isConst && obj.Parent != g.Root {
c.errorf(obj.Attributes.NearKey, "constant near keys can only be set on root level shapes")
continue
}
if !isKey && isConst && len(obj.ChildrenArray) > 0 {
c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on shapes with children")
continue
}
if !isKey && isConst {
if isKey {
// Doesn't make sense to set near to an ancestor or descendant
nearIsAncestor := false
for curr := obj; curr != nil; curr = curr.Parent {
if curr == nearObj {
nearIsAncestor = true
break
}
}
if nearIsAncestor {
c.errorf(obj.Attributes.NearKey, "near keys cannot be set to an ancestor")
continue
}
nearIsDescendant := false
for curr := nearObj; curr != nil; curr = curr.Parent {
if curr == obj {
nearIsDescendant = true
break
}
}
if nearIsDescendant {
c.errorf(obj.Attributes.NearKey, "near keys cannot be set to an descendant")
continue
}
if nearObj.OuterSequenceDiagram() != nil {
c.errorf(obj.Attributes.NearKey, "near keys cannot be set to an object within sequence diagrams")
continue
}
} else if isConst {
is := false
for _, e := range g.Edges {
if e.Src == obj || e.Dst == obj {
@ -646,6 +701,17 @@ func (c *compiler) validateNear(g *d2graph.Graph) {
c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on connected shapes")
continue
}
if obj.Parent != g.Root {
c.errorf(obj.Attributes.NearKey, "constant near keys can only be set on root level shapes")
continue
}
if len(obj.ChildrenArray) > 0 {
c.errorf(obj.Attributes.NearKey, "constant near keys cannot be set on shapes with children")
continue
}
} else {
c.errorf(obj.Attributes.NearKey, "near key %#v must be the absolute path to a shape or one of the following constants: %s", d2format.Format(obj.Attributes.NearKey), strings.Join(d2graph.NearConstantsArray, ", "))
continue
}
}
}

View file

@ -86,7 +86,6 @@ x: {
}
},
},
{
name: "dimensions_on_nonimage",
@ -114,6 +113,26 @@ x: {
}
},
},
{
name: "positions",
text: `hey: {
top: 200
left: 230
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, "200", g.Objects[0].Attributes.Top.Value)
},
},
{
name: "positions_negative",
text: `hey: {
top: 200
left: -200
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/positions_negative.d2:3:8: left must be a non-negative integer: "-200"`,
},
{
name: "equal_dimensions_on_circle",
@ -153,8 +172,7 @@ d2/testdata/d2compiler/TestCompile/equal_dimensions_on_circle.d2:4:2: width and
},
},
{
name: "no_dimensions_on_containers",
name: "dimensions_on_containers",
text: `
containers: {
circle container: {
@ -201,13 +219,6 @@ containers: {
}
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:5:3: width cannot be used on container: containers.circle container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:15:3: width cannot be used on container: containers.diamond container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:16:3: height cannot be used on container: containers.diamond container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:25:3: width cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:26:3: height cannot be used on container: containers.oval container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:36:3: width cannot be used on container: containers.hexagon container
d2/testdata/d2compiler/TestCompile/no_dimensions_on_containers.d2:37:3: height cannot be used on container: containers.hexagon container`,
},
{
name: "dimension_with_style",
@ -337,6 +348,17 @@ x: {
tassert.Equal(t, g.Objects[0].AbsID(), g.Objects[1].References[0].ScopeObj.AbsID())
},
},
{
name: "underscore_connection",
text: `a: {
_.c.d -> _.c.b
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, 4, len(g.Objects))
tassert.Equal(t, 1, len(g.Edges))
},
},
{
name: "underscore_parent_not_root",
@ -1356,6 +1378,21 @@ x -> y: {
}
},
},
{
name: "nil_scope_obj_regression",
text: `a
b: {
_.a
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, "a", g.Objects[0].ID)
for _, ref := range g.Objects[0].References {
tassert.NotNil(t, ref.ScopeObj)
}
},
},
{
name: "path_link",
@ -1377,6 +1414,29 @@ x -> y: {
text: `x.near: top-center
`,
},
{
name: "near-invalid",
text: `mongodb: MongoDB {
perspective: perspective (View) {
password
}
explanation: |md
perspective.model.js
| {
near: mongodb
}
}
a: {
near: a.b
b
}
`,
expErr: `d2/testdata/d2compiler/TestCompile/near-invalid.d2:9:11: near keys cannot be set to an ancestor
d2/testdata/d2compiler/TestCompile/near-invalid.d2:14:9: near keys cannot be set to an descendant`,
},
{
name: "near_bad_constant",
@ -1472,6 +1532,36 @@ d2/testdata/d2compiler/TestCompile/errors/reserved_icon_style.d2:2:9: near key "
shape: sql_table
x: {p -> q}
}`,
expErr: `d2/testdata/d2compiler/TestCompile/edge_in_column.d2:3:7: sql_table columns cannot have children
d2/testdata/d2compiler/TestCompile/edge_in_column.d2:3:12: sql_table columns cannot have children`,
},
{
name: "no-nested-columns-sql",
text: `x: {
shape: sql_table
a -- b.b
}`,
expErr: `d2/testdata/d2compiler/TestCompile/no-nested-columns-sql.d2:3:10: sql_table columns cannot have children`,
},
{
name: "no-nested-columns-sql-2",
text: `x: {
shape: sql_table
a
}
x.a.b`,
expErr: `d2/testdata/d2compiler/TestCompile/no-nested-columns-sql-2.d2:5:5: sql_table columns cannot have children`,
},
{
name: "no-nested-columns-class",
text: `x: {
shape: class
a.a
}`,
expErr: `d2/testdata/d2compiler/TestCompile/no-nested-columns-class.d2:3:5: class fields cannot have children`,
},
{
name: "edge_to_style",
@ -1660,7 +1750,7 @@ x.y -> a.b: {
text: `SVP1.shape: oval
SVP1.style.3d: true`,
expErr: `d2/testdata/d2compiler/TestCompile/3d_oval.d2:2:1: key "3d" can only be applied to squares and rectangles`,
expErr: `d2/testdata/d2compiler/TestCompile/3d_oval.d2:2:1: key "3d" can only be applied to squares, rectangles, and hexagons`,
}, {
name: "edge_column_index",
text: `src: {
@ -1699,6 +1789,44 @@ dst.id <-> src.dst_id
assert.String(t, "sequence_diagram", g.Objects[0].Attributes.Shape.Value)
},
},
{
name: "near_sequence",
text: `x: {
shape: sequence_diagram
a
}
b.near: x.a
`,
expErr: `d2/testdata/d2compiler/TestCompile/near_sequence.d2:5:9: near keys cannot be set to an object within sequence diagrams`,
},
{
name: "sequence-timestamp",
text: `shape: sequence_diagram
a
b
"04:20,11:20": {
"loop through each table": {
a."start_time = datetime.datetime.now"
a -> b
}
}
`,
assertions: func(t *testing.T, g *d2graph.Graph) {
tassert.Equal(t, 1, len(g.Edges))
tassert.Equal(t, 5, len(g.Objects))
tassert.Equal(t, "a", g.Objects[0].ID)
tassert.Equal(t, "b", g.Objects[1].ID)
tassert.Equal(t, `"04:20,11:20"`, g.Objects[2].ID)
tassert.Equal(t, `loop through each table`, g.Objects[3].ID)
tassert.Equal(t, 1, len(g.Objects[0].ChildrenArray))
tassert.Equal(t, 0, len(g.Objects[1].ChildrenArray))
tassert.Equal(t, 1, len(g.Objects[2].ChildrenArray))
tassert.True(t, g.Edges[0].ContainedBy(g.Objects[3]))
},
},
{
name: "root_sequence",
@ -2023,9 +2151,9 @@ layers: {
}
}
`, "")
assert.JSON(t, 2, len(g.Layers))
assert.JSON(t, "one", g.Layers[0].Name)
assert.JSON(t, "two", g.Layers[1].Name)
assert.Equal(t, 2, len(g.Layers))
assert.Equal(t, "one", g.Layers[0].Name)
assert.Equal(t, "two", g.Layers[1].Name)
},
},
{
@ -2056,6 +2184,37 @@ layers: {
assert.Equal(t, 2, len(g.Layers[1].Steps))
},
},
{
name: "isFolderOnly",
run: func(t *testing.T) {
g := assertCompile(t, `
layers: {
one: {
santa
}
two: {
clause
scenarios: {
seinfeld: {
}
missoula: {
steps: {
missus: one two three
}
}
}
}
}
`, "")
assert.True(t, g.IsFolderOnly)
assert.Equal(t, 2, len(g.Layers))
assert.Equal(t, "one", g.Layers[0].Name)
assert.Equal(t, "two", g.Layers[1].Name)
assert.Equal(t, 2, len(g.Layers[1].Scenarios))
assert.False(t, g.Layers[1].Scenarios[0].IsFolderOnly)
assert.False(t, g.Layers[1].Scenarios[1].IsFolderOnly)
},
},
{
name: "errs/duplicate_board",
run: func(t *testing.T) {

View file

@ -9,15 +9,14 @@ import (
"oss.terrastruct.com/d2/d2graph"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/color"
)
func Export(ctx context.Context, g *d2graph.Graph, themeID int64, fontFamily *d2fonts.FontFamily) (*d2target.Diagram, error) {
theme := d2themescatalog.Find(themeID)
func Export(ctx context.Context, g *d2graph.Graph, fontFamily *d2fonts.FontFamily) (*d2target.Diagram, error) {
diagram := d2target.NewDiagram()
applyStyles(&diagram.Root, g.Root)
diagram.Name = g.Name
diagram.IsFolderOnly = g.IsFolderOnly
if fontFamily == nil {
fontFamily = go2.Pointer(d2fonts.SourceSansPro)
}
@ -25,27 +24,27 @@ func Export(ctx context.Context, g *d2graph.Graph, themeID int64, fontFamily *d2
diagram.Shapes = make([]d2target.Shape, len(g.Objects))
for i := range g.Objects {
diagram.Shapes[i] = toShape(g.Objects[i], &theme)
diagram.Shapes[i] = toShape(g.Objects[i])
}
diagram.Connections = make([]d2target.Connection, len(g.Edges))
for i := range g.Edges {
diagram.Connections[i] = toConnection(g.Edges[i], &theme)
diagram.Connections[i] = toConnection(g.Edges[i])
}
return diagram, nil
}
func applyTheme(shape *d2target.Shape, obj *d2graph.Object, theme *d2themes.Theme) {
shape.Stroke = obj.GetStroke(theme, shape.StrokeDash)
shape.Fill = obj.GetFill(theme)
func applyTheme(shape *d2target.Shape, obj *d2graph.Object) {
shape.Stroke = obj.GetStroke(shape.StrokeDash)
shape.Fill = obj.GetFill()
if obj.Attributes.Shape.Value == d2target.ShapeText {
shape.Color = theme.Colors.Neutrals.N1
shape.Color = color.N1
}
if obj.Attributes.Shape.Value == d2target.ShapeSQLTable || obj.Attributes.Shape.Value == d2target.ShapeClass {
shape.PrimaryAccentColor = theme.Colors.B2
shape.SecondaryAccentColor = theme.Colors.AA2
shape.NeutralAccentColor = theme.Colors.Neutrals.N2
shape.PrimaryAccentColor = color.B2
shape.SecondaryAccentColor = color.AA2
shape.NeutralAccentColor = color.N2
}
}
@ -100,7 +99,7 @@ func applyStyles(shape *d2target.Shape, obj *d2graph.Object) {
}
}
func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
func toShape(obj *d2graph.Object) d2target.Shape {
shape := d2target.BaseShape()
shape.SetType(obj.Attributes.Shape.Value)
shape.ID = obj.AbsID()
@ -125,8 +124,8 @@ func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
}
applyStyles(shape, obj)
applyTheme(shape, obj, theme)
shape.Color = text.GetColor(theme, shape.Italic)
applyTheme(shape, obj)
shape.Color = text.GetColor(shape.Italic)
applyStyles(shape, obj)
switch obj.Attributes.Shape.Value {
@ -136,10 +135,10 @@ func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
case d2target.ShapeClass:
shape.Class = *obj.Class
// The label is the header for classes and tables, which is set in client to be 4 px larger than the object's set font size
shape.FontSize -= 4
shape.FontSize -= d2target.HeaderFontAdd
case d2target.ShapeSQLTable:
shape.SQLTable = *obj.SQLTable
shape.FontSize -= 4
shape.FontSize -= d2target.HeaderFontAdd
}
shape.Label = text.Text
shape.LabelWidth = text.Dimensions.Width
@ -147,10 +146,17 @@ func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
shape.LabelHeight = text.Dimensions.Height
if obj.LabelPosition != nil {
shape.LabelPosition = *obj.LabelPosition
if obj.IsSequenceDiagramGroup() {
shape.LabelFill = shape.Fill
}
}
shape.Tooltip = obj.Attributes.Tooltip
shape.Link = obj.Attributes.Link.Value
if obj.Attributes.Tooltip != nil {
shape.Tooltip = obj.Attributes.Tooltip.Value
}
if obj.Attributes.Link != nil {
shape.Link = obj.Attributes.Link.Value
}
shape.Icon = obj.Attributes.Icon
if obj.IconPosition != nil {
shape.IconPosition = *obj.IconPosition
@ -159,11 +165,10 @@ func toShape(obj *d2graph.Object, theme *d2themes.Theme) d2target.Shape {
return *shape
}
func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection {
func toConnection(edge *d2graph.Edge) d2target.Connection {
connection := d2target.BaseConnection()
connection.ID = edge.AbsID()
connection.ZIndex = edge.ZIndex
// edge.Edge.ID = go2.StringToIntHash(connection.ID)
text := edge.Text()
if edge.SrcArrow {
@ -208,7 +213,7 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
if edge.Attributes.Style.StrokeDash != nil {
connection.StrokeDash, _ = strconv.ParseFloat(edge.Attributes.Style.StrokeDash.Value, 64)
}
connection.Stroke = edge.GetStroke(theme, connection.StrokeDash)
connection.Stroke = edge.GetStroke(connection.StrokeDash)
if edge.Attributes.Style.Stroke != nil {
connection.Stroke = edge.Attributes.Style.Stroke.Value
}
@ -230,14 +235,16 @@ func toConnection(edge *d2graph.Edge, theme *d2themes.Theme) d2target.Connection
connection.Animated, _ = strconv.ParseBool(edge.Attributes.Style.Animated.Value)
}
connection.Tooltip = edge.Attributes.Tooltip
if edge.Attributes.Tooltip != nil {
connection.Tooltip = edge.Attributes.Tooltip.Value
}
connection.Icon = edge.Attributes.Icon
if edge.Attributes.Style.Italic != nil {
connection.Italic, _ = strconv.ParseBool(edge.Attributes.Style.Italic.Value)
}
connection.Color = text.GetColor(theme, connection.Italic)
connection.Color = text.GetColor(connection.Italic)
if edge.Attributes.Style.FontColor != nil {
connection.Color = edge.Attributes.Style.FontColor.Value
}

View file

@ -18,16 +18,14 @@ import (
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2layouts/d2sequence"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes/d2themescatalog"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
type testCase struct {
name string
dsl string
themeID int64
name string
dsl string
assertions func(t *testing.T, d *d2target.Diagram)
}
@ -132,8 +130,7 @@ func testConnection(t *testing.T) {
{
// This is a regression test where a connection with stroke-dash of 0 on terrastruct flagship theme would have a diff color
// than a connection without stroke dash
themeID: d2themescatalog.FlagshipTerrastruct.ID,
name: "theme_stroke-dash",
name: "theme_stroke-dash",
dsl: `x -> y: { style.stroke-dash: 0 }
x -> y
`,
@ -168,16 +165,14 @@ func testLabel(t *testing.T) {
func testTheme(t *testing.T) {
tcs := []testCase{
{
name: "shape_without_bold",
themeID: d2themescatalog.FlagshipTerrastruct.ID,
name: "shape_without_bold",
dsl: `x: {
style.bold: false
}
`,
},
{
name: "shape_with_italic",
themeID: d2themescatalog.FlagshipTerrastruct.ID,
name: "shape_with_italic",
dsl: `x: {
style.italic: true
}
@ -187,7 +182,6 @@ func testTheme(t *testing.T) {
name: "connection_without_italic",
dsl: `x -> y: asdf { style.italic: false }
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
{
name: "connection_with_italic",
@ -195,7 +189,6 @@ func testTheme(t *testing.T) {
style.italic: true
}
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
{
name: "connection_with_bold",
@ -203,7 +196,6 @@ func testTheme(t *testing.T) {
style.bold: true
}
`,
themeID: d2themescatalog.FlagshipTerrastruct.ID,
},
}
@ -244,7 +236,7 @@ func run(t *testing.T, tc testCase) {
t.Fatal(err)
}
got, err := d2exporter.Export(ctx, g, tc.themeID, nil)
got, err := d2exporter.Export(ctx, g, nil)
if err != nil {
t.Fatal(err)
}

View file

@ -17,7 +17,7 @@ import (
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2latex"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/shape"
"oss.terrastruct.com/d2/lib/textmeasure"
@ -28,8 +28,12 @@ const DEFAULT_SHAPE_SIZE = 100.
const MIN_SHAPE_SIZE = 5
type Graph struct {
Name string `json:"name"`
AST *d2ast.Map `json:"ast"`
Name string `json:"name"`
// IsFolderOnly indicates a board or scenario itself makes no modifications from its
// base. Folder only boards do not have a render and are used purely for organizing
// the board tree.
IsFolderOnly bool `json:"isFolderOnly"`
AST *d2ast.Map `json:"ast"`
Root *Object `json:"root"`
Edges []*Edge `json:"edges"`
@ -95,13 +99,15 @@ type Attributes struct {
Label Scalar `json:"label"`
Style Style `json:"style"`
Icon *url.URL `json:"icon,omitempty"`
Tooltip string `json:"tooltip,omitempty"`
Link Scalar `json:"link"`
Tooltip *Scalar `json:"tooltip,omitempty"`
Link *Scalar `json:"link,omitempty"`
// Only applicable for images right now
Width *Scalar `json:"width,omitempty"`
Height *Scalar `json:"height,omitempty"`
Top *Scalar `json:"top,omitempty"`
Left *Scalar `json:"left,omitempty"`
// TODO consider separate Attributes struct for shape-specific and edge-specific
// Shapes only
NearKey *d2ast.KeyPath `json:"near_key"`
@ -334,14 +340,20 @@ func (l ContainerLevel) LabelSize() int {
return d2fonts.FONT_SIZE_M
}
func (obj *Object) GetFill(theme *d2themes.Theme) string {
func (obj *Object) GetFill() string {
level := int(obj.Level())
shape := obj.Attributes.Shape.Value
if strings.EqualFold(shape, d2target.ShapeSQLTable) || strings.EqualFold(shape, d2target.ShapeClass) {
return color.N1
}
if obj.IsSequenceDiagramNote() {
return theme.Colors.Neutrals.N7
return color.N7
} else if obj.IsSequenceDiagramGroup() {
return theme.Colors.Neutrals.N5
return color.N5
} else if obj.Parent.IsSequenceDiagram() {
return theme.Colors.B5
return color.B5
}
// fill for spans
@ -349,85 +361,79 @@ func (obj *Object) GetFill(theme *d2themes.Theme) string {
if sd != nil {
level -= int(sd.Level())
if level == 1 {
return theme.Colors.B3
return color.B3
} else if level == 2 {
return theme.Colors.B4
return color.B4
} else if level == 3 {
return theme.Colors.B5
return color.B5
} else if level == 4 {
return theme.Colors.Neutrals.N6
return color.N6
}
return theme.Colors.Neutrals.N7
return color.N7
}
if obj.IsSequenceDiagram() {
return theme.Colors.Neutrals.N7
return color.N7
}
shape := obj.Attributes.Shape.Value
if shape == "" || strings.EqualFold(shape, d2target.ShapeSquare) || strings.EqualFold(shape, d2target.ShapeCircle) || strings.EqualFold(shape, d2target.ShapeOval) || strings.EqualFold(shape, d2target.ShapeRectangle) {
if level == 1 {
if !obj.IsContainer() {
return theme.Colors.B6
return color.B6
}
return theme.Colors.B4
return color.B4
} else if level == 2 {
return theme.Colors.B5
return color.B5
} else if level == 3 {
return theme.Colors.B6
return color.B6
}
return theme.Colors.Neutrals.N7
return color.N7
}
if strings.EqualFold(shape, d2target.ShapeCylinder) || strings.EqualFold(shape, d2target.ShapeStoredData) || strings.EqualFold(shape, d2target.ShapePackage) {
if level == 1 {
return theme.Colors.AA4
return color.AA4
}
return theme.Colors.AA5
return color.AA5
}
if strings.EqualFold(shape, d2target.ShapeStep) || strings.EqualFold(shape, d2target.ShapePage) || strings.EqualFold(shape, d2target.ShapeDocument) {
if level == 1 {
return theme.Colors.AB4
return color.AB4
}
return theme.Colors.AB5
return color.AB5
}
if strings.EqualFold(shape, d2target.ShapePerson) {
return theme.Colors.B3
return color.B3
}
if strings.EqualFold(shape, d2target.ShapeDiamond) {
return theme.Colors.Neutrals.N4
return color.N4
}
if strings.EqualFold(shape, d2target.ShapeCloud) || strings.EqualFold(shape, d2target.ShapeCallout) {
return theme.Colors.Neutrals.N7
return color.N7
}
if strings.EqualFold(shape, d2target.ShapeQueue) || strings.EqualFold(shape, d2target.ShapeParallelogram) || strings.EqualFold(shape, d2target.ShapeHexagon) {
return theme.Colors.Neutrals.N5
return color.N5
}
if strings.EqualFold(shape, d2target.ShapeSQLTable) || strings.EqualFold(shape, d2target.ShapeClass) {
return theme.Colors.Neutrals.N1
}
return theme.Colors.Neutrals.N7
return color.N7
}
func (obj *Object) GetStroke(theme *d2themes.Theme, dashGapSize interface{}) string {
func (obj *Object) GetStroke(dashGapSize interface{}) string {
shape := obj.Attributes.Shape.Value
if strings.EqualFold(shape, d2target.ShapeCode) ||
strings.EqualFold(shape, d2target.ShapeText) {
return theme.Colors.Neutrals.N1
return color.N1
}
if strings.EqualFold(shape, d2target.ShapeClass) ||
strings.EqualFold(shape, d2target.ShapeSQLTable) {
return theme.Colors.Neutrals.N7
return color.N7
}
if dashGapSize != 0.0 {
return theme.Colors.B2
return color.B2
}
return theme.Colors.B1
return color.B1
}
func (obj *Object) Level() ContainerLevel {
@ -465,6 +471,11 @@ func (obj *Object) Text() *d2target.MText {
isItalic = true
}
fontSize := d2fonts.FONT_SIZE_M
if obj.Class != nil || obj.SQLTable != nil {
fontSize = d2fonts.FONT_SIZE_L
}
if obj.OuterSequenceDiagram() == nil {
if obj.IsContainer() {
fontSize = obj.Level().LabelSize()
@ -477,7 +488,7 @@ func (obj *Object) Text() *d2target.MText {
}
// Class and Table objects have Label set to header
if obj.Class != nil || obj.SQLTable != nil {
fontSize = d2fonts.FONT_SIZE_XL
fontSize += d2target.HeaderFontAdd
}
if obj.Class != nil {
isBold = false
@ -553,6 +564,38 @@ func (obj *Object) HasChild(ids []string) (*Object, bool) {
return child, true
}
// Keep in sync with EnsureChild.
func (obj *Object) EnsureChildIDVal(ids []string) *Object {
if len(ids) == 0 {
return obj
}
if len(ids) == 1 && ids[0] != "style" {
_, ok := ReservedKeywords[ids[0]]
if ok {
return obj
}
}
id := ids[0]
ids = ids[1:]
var child *Object
for _, ch2 := range obj.ChildrenArray {
if ch2.IDVal == id {
child = ch2
break
}
}
if child == nil {
child = obj.newObject(id)
}
if len(ids) >= 1 {
return child.EnsureChildIDVal(ids)
}
return child
}
func (obj *Object) HasEdge(mk *d2ast.Key) (*Edge, bool) {
ea, ok := obj.FindEdges(mk)
if !ok {
@ -795,17 +838,22 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
case d2target.ShapeClass:
maxWidth := go2.Max(12, labelDims.Width)
fontSize := d2fonts.FONT_SIZE_L
if obj.Attributes.Style.FontSize != nil {
fontSize, _ = strconv.Atoi(obj.Attributes.Style.FontSize.Value)
}
for _, f := range obj.Class.Fields {
fdims := GetTextDimensions(mtexts, ruler, f.Text(), go2.Pointer(d2fonts.SourceCodePro))
fdims := GetTextDimensions(mtexts, ruler, f.Text(fontSize), go2.Pointer(d2fonts.SourceCodePro))
if fdims == nil {
return nil, fmt.Errorf("dimensions for class field %#v not found", f.Text())
return nil, fmt.Errorf("dimensions for class field %#v not found", f.Text(fontSize))
}
maxWidth = go2.Max(maxWidth, fdims.Width)
}
for _, m := range obj.Class.Methods {
mdims := GetTextDimensions(mtexts, ruler, m.Text(), go2.Pointer(d2fonts.SourceCodePro))
mdims := GetTextDimensions(mtexts, ruler, m.Text(fontSize), go2.Pointer(d2fonts.SourceCodePro))
if mdims == nil {
return nil, fmt.Errorf("dimensions for class method %#v not found", m.Text())
return nil, fmt.Errorf("dimensions for class method %#v not found", m.Text(fontSize))
}
maxWidth = go2.Max(maxWidth, mdims.Width)
}
@ -820,9 +868,9 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
// All rows should be the same height
var anyRowText *d2target.MText
if len(obj.Class.Fields) > 0 {
anyRowText = obj.Class.Fields[0].Text()
anyRowText = obj.Class.Fields[0].Text(fontSize)
} else if len(obj.Class.Methods) > 0 {
anyRowText = obj.Class.Methods[0].Text()
anyRowText = obj.Class.Methods[0].Text(fontSize)
}
if anyRowText != nil {
rowHeight := GetTextDimensions(mtexts, ruler, anyRowText, go2.Pointer(d2fonts.SourceCodePro)).Height + d2target.VerticalPadding
@ -836,10 +884,16 @@ func (obj *Object) GetDefaultSize(mtexts []*d2target.MText, ruler *textmeasure.R
maxTypeWidth := 0
constraintWidth := 0
colFontSize := d2fonts.FONT_SIZE_L
if obj.Attributes.Style.FontSize != nil {
colFontSize, _ = strconv.Atoi(obj.Attributes.Style.FontSize.Value)
}
for i := range obj.SQLTable.Columns {
// Note: we want to set dimensions of actual column not the for loop copy of the struct
c := &obj.SQLTable.Columns[i]
ctexts := c.Texts()
ctexts := c.Texts(colFontSize)
nameDims := GetTextDimensions(mtexts, ruler, ctexts[0], fontFamily)
if nameDims == nil {
@ -915,11 +969,11 @@ type EdgeReference struct {
ScopeObj *Object `json:"-"`
}
func (e *Edge) GetStroke(theme *d2themes.Theme, dashGapSize interface{}) string {
func (e *Edge) GetStroke(dashGapSize interface{}) string {
if dashGapSize != 0.0 {
return theme.Colors.B2
return color.B2
}
return theme.Colors.B1
return color.B1
}
func (e *Edge) ArrowString() string {
@ -1263,10 +1317,10 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
switch shapeType {
case shape.TABLE_TYPE, shape.CLASS_TYPE, shape.CODE_TYPE, shape.IMAGE_TYPE:
default:
if obj.Attributes.Link.Value != "" {
if obj.Attributes.Link != nil {
paddingX += 32
}
if obj.Attributes.Tooltip != "" {
if obj.Attributes.Tooltip != nil {
paddingX += 32
}
}
@ -1280,8 +1334,11 @@ func (g *Graph) SetDimensions(mtexts []*d2target.MText, ruler *textmeasure.Ruler
obj.Width = sideLength
obj.Height = sideLength
} else if desiredHeight == 0 || desiredWidth == 0 {
if s.GetType() == shape.PERSON_TYPE {
switch s.GetType() {
case shape.PERSON_TYPE:
obj.Width, obj.Height = shape.LimitAR(obj.Width, obj.Height, shape.PERSON_AR_LIMIT)
case shape.OVAL_TYPE:
obj.Width, obj.Height = shape.LimitAR(obj.Width, obj.Height, shape.OVAL_AR_LIMIT)
}
}
}
@ -1327,15 +1384,23 @@ func (g *Graph) Texts() []*d2target.MText {
texts = appendTextDedup(texts, obj.Text())
}
if obj.Class != nil {
fontSize := d2fonts.FONT_SIZE_L
if obj.Attributes.Style.FontSize != nil {
fontSize, _ = strconv.Atoi(obj.Attributes.Style.FontSize.Value)
}
for _, field := range obj.Class.Fields {
texts = appendTextDedup(texts, field.Text())
texts = appendTextDedup(texts, field.Text(fontSize))
}
for _, method := range obj.Class.Methods {
texts = appendTextDedup(texts, method.Text())
texts = appendTextDedup(texts, method.Text(fontSize))
}
} else if obj.SQLTable != nil {
colFontSize := d2fonts.FONT_SIZE_L
if obj.Attributes.Style.FontSize != nil {
colFontSize, _ = strconv.Atoi(obj.Attributes.Style.FontSize.Value)
}
for _, column := range obj.SQLTable.Columns {
for _, t := range column.Texts() {
for _, t := range column.Texts(colFontSize) {
texts = appendTextDedup(texts, t)
}
}
@ -1383,6 +1448,8 @@ var SimpleReservedKeywords = map[string]struct{}{
"width": {},
"height": {},
"direction": {},
"top": {},
"left": {},
}
// ReservedKeywordHolders are reserved keywords that are meaningless on its own and exist solely to hold a set of reserved keywords
@ -1523,3 +1590,13 @@ func (g *Graph) SortEdgesByAST() {
})
g.Edges = edges
}
func (obj *Object) IsDescendantOf(ancestor *Object) bool {
if obj == ancestor {
return true
}
if obj.Parent == nil {
return false
}
return obj.Parent.IsDescendantOf(ancestor)
}

View file

@ -2,8 +2,10 @@ package d2graph
import (
"encoding/json"
"fmt"
"strings"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/util-go/go2"
)
@ -24,10 +26,10 @@ func DeserializeGraph(bytes []byte, g *Graph) error {
return err
}
g.Root = &Object{
Graph: g,
Children: make(map[string]*Object),
}
var root Object
convert(sg.Root, &root)
g.Root = &root
root.Graph = g
idToObj := make(map[string]*Object)
idToObj[""] = g.Root
@ -49,7 +51,7 @@ func DeserializeGraph(bytes []byte, g *Graph) error {
for _, id := range so["ChildrenArray"].([]interface{}) {
o := idToObj[id.(string)]
childrenArray = append(childrenArray, o)
children[strings.ToLower(id.(string))] = o
children[strings.ToLower(o.ID)] = o
o.Parent = idToObj[so["AbsID"].(string)]
}
@ -158,3 +160,316 @@ func convert[T, Q any](from T, to *Q) error {
}
return nil
}
func CompareSerializedGraph(g, other *Graph) error {
if len(g.Objects) != len(other.Objects) {
return fmt.Errorf("object count differs: g=%d, other=%d", len(g.Objects), len(other.Objects))
}
if len(g.Edges) != len(other.Edges) {
return fmt.Errorf("edge count differs: g=%d, other=%d", len(g.Edges), len(other.Edges))
}
if err := CompareSerializedObject(g.Root, other.Root); err != nil {
return fmt.Errorf("root differs: %v", err)
}
for i := 0; i < len(g.Objects); i++ {
if err := CompareSerializedObject(g.Objects[i], other.Objects[i]); err != nil {
return fmt.Errorf(
"objects differ at %d [g=%s, other=%s]: %v",
i,
g.Objects[i].ID,
other.Objects[i].ID,
err,
)
}
}
for i := 0; i < len(g.Edges); i++ {
if err := CompareSerializedEdge(g.Edges[i], other.Edges[i]); err != nil {
return fmt.Errorf(
"edges differ at %d [g=%s, other=%s]: %v",
i,
g.Edges[i].AbsID(),
other.Edges[i].AbsID(),
err,
)
}
}
return nil
}
func CompareSerializedObject(obj, other *Object) error {
if obj != nil && other == nil {
return fmt.Errorf("other is nil")
} else if obj == nil && other != nil {
return fmt.Errorf("obj is nil")
} else if obj == nil {
// both are nil
return nil
}
if obj.ID != other.ID {
return fmt.Errorf("ids differ: obj=%s, other=%s", obj.ID, other.ID)
}
if obj.AbsID() != other.AbsID() {
return fmt.Errorf("absolute ids differ: obj=%s, other=%s", obj.AbsID(), other.AbsID())
}
if obj.Box != nil && other.Box == nil {
return fmt.Errorf("other should have a box")
} else if obj.Box == nil && other.Box != nil {
return fmt.Errorf("other should not have a box")
} else if obj.Box != nil {
if obj.Width != other.Width {
return fmt.Errorf("widths differ: obj=%f, other=%f", obj.Width, other.Width)
}
if obj.Height != other.Height {
return fmt.Errorf("heights differ: obj=%f, other=%f", obj.Height, other.Height)
}
}
if obj.Parent != nil && other.Parent == nil {
return fmt.Errorf("other should have a parent")
} else if obj.Parent == nil && other.Parent != nil {
return fmt.Errorf("other should not have a parent")
} else if obj.Parent != nil && obj.Parent.ID != other.Parent.ID {
return fmt.Errorf("parent differs: obj=%s, other=%s", obj.Parent.ID, other.Parent.ID)
}
if len(obj.Children) != len(other.Children) {
return fmt.Errorf("children count differs: obj=%d, other=%d", len(obj.Children), len(other.Children))
}
for childID, objChild := range obj.Children {
if otherChild, exists := other.Children[childID]; exists {
if err := CompareSerializedObject(objChild, otherChild); err != nil {
return fmt.Errorf("children differ at key %s: %v", childID, err)
}
} else {
return fmt.Errorf("child %s does not exist in other", childID)
}
}
if len(obj.ChildrenArray) != len(other.ChildrenArray) {
return fmt.Errorf("childrenArray count differs: obj=%d, other=%d", len(obj.ChildrenArray), len(other.ChildrenArray))
}
for i := 0; i < len(obj.ChildrenArray); i++ {
if err := CompareSerializedObject(obj.ChildrenArray[i], other.ChildrenArray[i]); err != nil {
return fmt.Errorf("childrenArray differs at %d: %v", i, err)
}
}
if obj.Attributes != nil && other.Attributes == nil {
return fmt.Errorf("other should have attributes")
} else if obj.Attributes == nil && other.Attributes != nil {
return fmt.Errorf("other should not have attributes")
} else if obj.Attributes != nil {
if d2target.IsShape(obj.Attributes.Shape.Value) != d2target.IsShape(other.Attributes.Shape.Value) {
return fmt.Errorf(
"shapes differ: obj=%s, other=%s",
obj.Attributes.Shape.Value,
other.Attributes.Shape.Value,
)
}
if obj.Attributes.Icon == nil && other.Attributes.Icon != nil {
return fmt.Errorf("other does not have an icon")
} else if obj.Attributes.Icon != nil && other.Attributes.Icon == nil {
return fmt.Errorf("obj does not have an icon")
}
if obj.Attributes.Direction.Value != other.Attributes.Direction.Value {
return fmt.Errorf(
"directions differ: obj=%s, other=%s",
obj.Attributes.Direction.Value,
other.Attributes.Direction.Value,
)
}
if obj.Attributes.Label.Value != other.Attributes.Label.Value {
return fmt.Errorf(
"labels differ: obj=%s, other=%s",
obj.Attributes.Label.Value,
other.Attributes.Label.Value,
)
}
if obj.Attributes.NearKey != nil {
if other.Attributes.NearKey == nil {
return fmt.Errorf("other does not have near")
}
objKey := strings.Join(Key(obj.Attributes.NearKey), ".")
deserKey := strings.Join(Key(other.Attributes.NearKey), ".")
if objKey != deserKey {
return fmt.Errorf(
"near differs: obj=%s, other=%s",
objKey,
deserKey,
)
}
} else if other.Attributes.NearKey != nil {
return fmt.Errorf("other should not have near")
}
}
if obj.SQLTable == nil && other.SQLTable != nil {
return fmt.Errorf("other is not a sql table")
} else if obj.SQLTable != nil && other.SQLTable == nil {
return fmt.Errorf("obj is not a sql table")
}
if obj.SQLTable != nil {
if len(obj.SQLTable.Columns) != len(other.SQLTable.Columns) {
return fmt.Errorf(
"table columns count differ: obj=%d, other=%d",
len(obj.SQLTable.Columns),
len(other.SQLTable.Columns),
)
}
}
if obj.LabelWidth != nil {
if other.LabelWidth == nil {
return fmt.Errorf("other does not have a label width")
}
if *obj.LabelWidth != *other.LabelWidth {
return fmt.Errorf(
"label widths differ: obj=%d, other=%d",
*obj.LabelWidth,
*other.LabelWidth,
)
}
} else if other.LabelWidth != nil {
return fmt.Errorf("other should not have label width")
}
if obj.LabelHeight != nil {
if other.LabelHeight == nil {
return fmt.Errorf("other does not have a label height")
}
if *obj.LabelHeight != *other.LabelHeight {
return fmt.Errorf(
"label heights differ: obj=%d, other=%d",
*obj.LabelHeight,
*other.LabelHeight,
)
}
} else if other.LabelHeight != nil {
return fmt.Errorf("other should not have label height")
}
return nil
}
func CompareSerializedEdge(edge, other *Edge) error {
if edge.AbsID() != other.AbsID() {
return fmt.Errorf(
"absolute ids differ: edge=%s, other=%s",
edge.AbsID(),
other.AbsID(),
)
}
if edge.Src.AbsID() != other.Src.AbsID() {
return fmt.Errorf(
"sources differ: edge=%s, other=%s",
edge.Src.AbsID(),
other.Src.AbsID(),
)
}
if edge.Dst.AbsID() != other.Dst.AbsID() {
return fmt.Errorf(
"targets differ: edge=%s, other=%s",
edge.Dst.AbsID(),
other.Dst.AbsID(),
)
}
if edge.SrcArrow != other.SrcArrow {
return fmt.Errorf(
"source arrows differ: edge=%t, other=%t",
edge.SrcArrow,
other.SrcArrow,
)
}
if edge.DstArrow != other.DstArrow {
return fmt.Errorf(
"target arrows differ: edge=%t, other=%t",
edge.DstArrow,
other.DstArrow,
)
}
if edge.MinWidth != other.MinWidth {
return fmt.Errorf(
"min width differs: edge=%d, other=%d",
edge.MinWidth,
other.MinWidth,
)
}
if edge.MinHeight != other.MinHeight {
return fmt.Errorf(
"min height differs: edge=%d, other=%d",
edge.MinHeight,
other.MinHeight,
)
}
if edge.Attributes.Label.Value != other.Attributes.Label.Value {
return fmt.Errorf(
"labels differ: edge=%s, other=%s",
edge.Attributes.Label.Value,
other.Attributes.Label.Value,
)
}
if edge.LabelDimensions.Width != other.LabelDimensions.Width {
return fmt.Errorf(
"label width differs: edge=%d, other=%d",
edge.LabelDimensions.Width,
other.LabelDimensions.Width,
)
}
if edge.LabelDimensions.Height != other.LabelDimensions.Height {
return fmt.Errorf(
"label hieght differs: edge=%d, other=%d",
edge.LabelDimensions.Height,
other.LabelDimensions.Height,
)
}
if edge.SrcTableColumnIndex != nil && other.SrcTableColumnIndex == nil {
return fmt.Errorf("other should have src column index")
} else if other.SrcTableColumnIndex != nil && edge.SrcTableColumnIndex == nil {
return fmt.Errorf("other should not have src column index")
} else if other.SrcTableColumnIndex != nil {
edgeColumn := *edge.SrcTableColumnIndex
otherColumn := *other.SrcTableColumnIndex
if edgeColumn != otherColumn {
return fmt.Errorf("src column differs: edge=%d, other=%d", edgeColumn, otherColumn)
}
}
if edge.DstTableColumnIndex != nil && other.DstTableColumnIndex == nil {
return fmt.Errorf("other should have dst column index")
} else if other.DstTableColumnIndex != nil && edge.DstTableColumnIndex == nil {
return fmt.Errorf("other should not have dst column index")
} else if other.DstTableColumnIndex != nil {
edgeColumn := *edge.DstTableColumnIndex
otherColumn := *other.DstTableColumnIndex
if edgeColumn != otherColumn {
return fmt.Errorf("dst column differs: edge=%d, other=%d", edgeColumn, otherColumn)
}
}
return nil
}

View file

@ -17,19 +17,19 @@ func TestSerialization(t *testing.T) {
assert.Nil(t, err)
asserts := func(g *d2graph.Graph) {
a := g.Root.ChildrenArray[0]
a_a := a.ChildrenArray[0]
assert.Equal(t, 4, len(g.Objects))
assert.Equal(t, 1, len(g.Root.ChildrenArray))
assert.Equal(t, 1, len(g.Root.ChildrenArray[0].ChildrenArray))
assert.Equal(t, 2, len(g.Root.ChildrenArray[0].ChildrenArray[0].ChildrenArray))
assert.Equal(t,
g.Root.ChildrenArray[0],
g.Root.ChildrenArray[0].ChildrenArray[0].Parent,
)
assert.Equal(t, 1, len(a.ChildrenArray))
assert.Equal(t, 2, len(a_a.ChildrenArray))
assert.Equal(t, a, a_a.Parent)
assert.Equal(t, g.Root, a.Parent)
assert.Equal(t,
g.Root,
g.Root.ChildrenArray[0].Parent,
)
assert.Contains(t, a.Children, "a")
assert.Contains(t, a_a.Children, "b")
assert.Contains(t, a_a.Children, "c")
assert.Equal(t, 1, len(g.Edges))
assert.Equal(t, "b", g.Edges[0].Src.ID)

View file

@ -38,7 +38,8 @@ func (c *compiler) compileScenarios(m *Map) {
}
for _, sf := range scenarios.Fields {
if sf.Map() == nil {
if sf.Map() == nil || sf.Primary() != nil {
c.errorf(sf.References[0].Context.Key, "invalid scenario")
continue
}
base := m.CopyBase(sf)
@ -59,8 +60,9 @@ func (c *compiler) compileSteps(m *Map) {
return
}
for i, sf := range steps.Fields {
if sf.Map() == nil {
continue
if sf.Map() == nil || sf.Primary() != nil {
c.errorf(sf.References[0].Context.Key, "invalid step")
break
}
var base *Map
if i == 0 {

View file

@ -420,6 +420,22 @@ steps: {
assertQuery(t, m, 0, 0, nil, "steps.nuclear.quiche")
},
},
{
name: "steps_panic",
run: func(t testing.TB) {
_, err := compile(t, `steps: {
shape: sql_table
id: int {constraint: primary_key}
}
scenarios: {
shape: sql_table
hey: int {constraint: primary_key}
}`)
assert.ErrorString(t, err, `TestCompile/steps/steps_panic.d2:6:3: invalid scenario
TestCompile/steps/steps_panic.d2:7:3: invalid scenario
TestCompile/steps/steps_panic.d2:2:3: invalid step`)
},
},
{
name: "recursive",
run: func(t testing.TB) {

View file

@ -23,6 +23,7 @@ type Node interface {
Parent() Node
Primary() *Scalar
Map() *Map
Equal(n2 Node) bool
ast() d2ast.Node
fmt.Stringer
@ -139,7 +140,8 @@ func (s *Scalar) Copy(newParent Node) Node {
return s
}
func (s *Scalar) Equal(s2 *Scalar) bool {
func (s *Scalar) Equal(n2 Node) bool {
s2 := n2.(*Scalar)
if _, ok := s.Value.(d2ast.String); ok {
if _, ok = s2.Value.(d2ast.String); ok {
return s.Value.ScalarString() == s2.Value.ScalarString()
@ -187,6 +189,10 @@ func (m *Map) Copy(newParent Node) Node {
// CopyBase copies the map m without layers/scenarios/steps.
func (m *Map) CopyBase(newParent Node) *Map {
if m == nil {
return (&Map{}).Copy(newParent).(*Map)
}
layers := m.DeleteField("layers")
scenarios := m.DeleteField("scenarios")
steps := m.DeleteField("steps")
@ -233,7 +239,7 @@ func NodeBoardKind(n Node) BoardKind {
var f *Field
switch n := n.(type) {
case *Field:
if n.Name == "" {
if n.parent == nil {
return BoardLayer
}
f = ParentField(n)
@ -379,7 +385,9 @@ func (eid *EdgeID) Match(eid2 *EdgeID) bool {
return true
}
func (eid *EdgeID) resolveUnderscores(m *Map) (*EdgeID, *Map, error) {
// resolve resolves both underscores and commons in eid.
// It returns the new eid, containing map adjusted for underscores and common ida.
func (eid *EdgeID) resolve(m *Map) (_ *EdgeID, _ *Map, common []string, _ error) {
eid = eid.Copy()
maxUnderscores := go2.Max(countUnderscores(eid.SrcPath), countUnderscores(eid.DstPath))
for i := 0; i < maxUnderscores; i++ {
@ -397,23 +405,20 @@ func (eid *EdgeID) resolveUnderscores(m *Map) (*EdgeID, *Map, error) {
}
m = ParentMap(m)
if m == nil {
return nil, nil, errors.New("invalid underscore")
return nil, nil, nil, errors.New("invalid underscore")
}
}
return eid, m, nil
}
func (eid *EdgeID) trimCommon() (common []string, _ *EdgeID) {
eid = eid.Copy()
for len(eid.SrcPath) > 1 && len(eid.DstPath) > 1 {
if !strings.EqualFold(eid.SrcPath[0], eid.DstPath[0]) {
return common, eid
return eid, m, common, nil
}
common = append(common, eid.SrcPath[0])
eid.SrcPath = eid.SrcPath[1:]
eid.DstPath = eid.DstPath[1:]
}
return common, eid
return eid, m, common, nil
}
type Edge struct {
@ -732,11 +737,10 @@ func (m *Map) DeleteField(ida ...string) *Field {
}
func (m *Map) GetEdges(eid *EdgeID) []*Edge {
eid, m, err := eid.resolveUnderscores(m)
eid, m, common, err := eid.resolve(m)
if err != nil {
return nil
}
common, eid := eid.trimCommon()
if len(common) > 0 {
f := m.GetField(common...)
if f == nil {
@ -762,16 +766,12 @@ func (m *Map) CreateEdge(eid *EdgeID, refctx *RefContext) (*Edge, error) {
return nil, d2parser.Errorf(refctx.Edge, "cannot create edge inside edge")
}
eid, m, err := eid.resolveUnderscores(m)
eid, m, common, err := eid.resolve(m)
if err != nil {
return nil, d2parser.Errorf(refctx.Edge, err.Error())
}
common, eid := eid.trimCommon()
if len(common) > 0 {
tmp := *refctx.Edge.Src
kp := &tmp
kp.Path = kp.Path[:len(common)]
f, err := m.EnsureField(kp, nil)
f, err := m.EnsureField(d2ast.MakeKeyPath(common), nil)
if err != nil {
return nil, err
}
@ -1033,11 +1033,12 @@ func parentPrimaryKey(n Node) *d2ast.Key {
return nil
}
// IDA returns the absolute path to n from the nearest board root.
func IDA(n Node) (ida []string) {
for {
f, ok := n.(*Field)
if ok {
if f.Root() {
if f.Root() || NodeBoardKind(f) != "" {
reverseIDA(ida)
return ida
}
@ -1059,3 +1060,73 @@ func reverseIDA(ida []string) {
ida[len(ida)-i-1] = tmp
}
}
func (f *Field) Equal(n2 Node) bool {
f2 := n2.(*Field)
if f.Name != f2.Name {
return false
}
if !f.Primary_.Equal(f2.Primary_) {
return false
}
if !f.Composite.Equal(f2.Composite) {
return false
}
return true
}
func (e *Edge) Equal(n2 Node) bool {
e2 := n2.(*Edge)
if !e.ID.Match(e2.ID) {
return false
}
if !e.Primary_.Equal(e2.Primary_) {
return false
}
if !e.Map_.Equal(e2.Map_) {
return false
}
return true
}
func (a *Array) Equal(n2 Node) bool {
a2 := n2.(*Array)
if len(a.Values) != len(a2.Values) {
return false
}
for i := range a.Values {
if !a.Values[i].Equal(a2.Values[i]) {
return false
}
}
return true
}
func (m *Map) Equal(n2 Node) bool {
m2 := n2.(*Map)
if len(m.Fields) != len(m2.Fields) {
return false
}
if len(m.Edges) != len(m2.Edges) {
return false
}
for i := range m.Fields {
if !m.Fields[i].Equal(m2.Fields[i]) {
return false
}
}
for i := range m.Edges {
if !m.Edges[i].Equal(m2.Edges[i]) {
return false
}
}
return true
}

View file

@ -30,7 +30,10 @@ var setupJS string
//go:embed dagre.js
var dagreJS string
const MIN_SEGMENT_LEN = 10
const (
MIN_SEGMENT_LEN = 10
MIN_RANK_SEP = 60
)
type ConfigurableOpts struct {
NodeSep int `json:"nodesep"`
@ -39,7 +42,7 @@ type ConfigurableOpts struct {
var DefaultOpts = ConfigurableOpts{
NodeSep: 60,
EdgeSep: 40,
EdgeSep: 20,
}
type DagreNode struct {
@ -104,6 +107,25 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
rootAttrs.rankdir = "TB"
}
maxContainerLabelHeight := 0
for _, obj := range g.Objects {
if len(obj.ChildrenArray) == 0 || obj.Parent == g.Root {
continue
}
if obj.LabelHeight != nil {
maxContainerLabelHeight = go2.Max(maxContainerLabelHeight, *obj.LabelHeight+label.PADDING)
}
if obj.Attributes.Icon != nil && obj.Attributes.Shape.Value != d2target.ShapeImage {
contentBox := geo.NewBox(geo.NewPoint(0, 0), float64(obj.Width), float64(obj.Height))
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[obj.Attributes.Shape.Value]
s := shape.NewShape(shapeType, contentBox)
iconSize := d2target.GetIconSize(s.GetInnerBox(), string(label.InsideTopLeft))
// Since dagre container labels are pushed up, we don't want a child container to collide
maxContainerLabelHeight = go2.Max(maxContainerLabelHeight, (iconSize+label.PADDING*2)*2)
}
}
maxLabelSize := 0
for _, edge := range g.Edges {
size := edge.LabelDimensions.Width
@ -112,7 +134,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
maxLabelSize = go2.Max(maxLabelSize, size)
}
rootAttrs.ranksep = go2.Max(100, maxLabelSize+40)
rootAttrs.ranksep = go2.Max(go2.Max(100, maxLabelSize+40), maxContainerLabelHeight)
configJS := setGraphAttrs(rootAttrs)
if _, err := vm.RunString(configJS); err != nil {
@ -130,6 +152,9 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
height += float64(*obj.LabelHeight) + label.PADDING
}
if len(obj.ChildrenArray) > 0 {
height += float64(*obj.LabelHeight) + label.PADDING
}
}
loadScript += generateAddNodeLine(id, int(obj.Width), int(height))
if obj.Parent != g.Root {
@ -191,7 +216,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if len(obj.ChildrenArray) > 0 {
obj.LabelPosition = go2.Pointer(string(label.InsideTopCenter))
obj.LabelPosition = go2.Pointer(string(label.OutsideTopCenter))
} else if obj.Attributes.Shape.Value == d2target.ShapeImage {
obj.LabelPosition = go2.Pointer(string(label.OutsideBottomCenter))
// remove the extra height we added to the node when passing to dagre
@ -203,7 +228,12 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
}
if obj.Attributes.Icon != nil {
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
if len(obj.ChildrenArray) > 0 {
obj.IconPosition = go2.Pointer(string(label.OutsideTopLeft))
obj.LabelPosition = go2.Pointer(string(label.OutsideTopRight))
} else {
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
}
@ -248,6 +278,118 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
}
}
points = points[startIndex : endIndex+1]
points[0] = start
points[len(points)-1] = end
edge.Route = points
}
for _, obj := range g.Objects {
if obj.LabelHeight == nil || len(obj.ChildrenArray) <= 0 {
continue
}
// usually you don't want to take away here more than what was added, which is the label height
// however, if the label height is more than the ranksep/2, we'll have no padding around children anymore
// so cap the amount taken off at ranksep/2
subtract := float64(go2.Min(rootAttrs.ranksep/2, *obj.LabelHeight+label.PADDING))
obj.Height -= subtract
// If the edge is connected to two descendants that are about to be downshifted, their whole route gets downshifted
movedEdges := make(map[*d2graph.Edge]struct{})
for _, e := range g.Edges {
isSrcDesc := e.Src.IsDescendantOf(obj)
isDstDesc := e.Dst.IsDescendantOf(obj)
if isSrcDesc && isDstDesc {
stepSize := subtract
if e.Src != obj || e.Dst != obj {
stepSize /= 2.
}
movedEdges[e] = struct{}{}
for _, p := range e.Route {
p.Y += stepSize
}
}
}
q := []*d2graph.Object{obj}
// Downshift descendants and edges that have one endpoint connected to a descendant
for len(q) > 0 {
curr := q[0]
q = q[1:]
stepSize := subtract
// The object itself needs to move down the height it was just subtracted
// all descendants move half, to maintain vertical padding
if curr != obj {
stepSize /= 2.
}
curr.TopLeft.Y += stepSize
almostEqual := func(a, b float64) bool {
return b-1 <= a && a <= b+1
}
shouldMove := func(p *geo.Point) bool {
if curr != obj {
return true
}
if isHorizontal {
// Only move horizontal edges if they are connected to the top side of the shrinking container
return almostEqual(p.Y, obj.TopLeft.Y-stepSize)
} else {
// Edge should only move if it's not connected to the bottom side of the shrinking container
return !almostEqual(p.Y, obj.TopLeft.Y+obj.Height)
}
}
for _, e := range g.Edges {
if _, ok := movedEdges[e]; ok {
continue
}
moveWholeEdge := false
if e.Src == curr {
// Don't move src points on side of container
if almostEqual(e.Route[0].X, obj.TopLeft.X) || almostEqual(e.Route[0].X, obj.TopLeft.X+obj.Width) {
// Unless the dst is also on a container
if e.Dst.LabelHeight == nil || len(e.Dst.ChildrenArray) <= 0 {
continue
}
}
if shouldMove(e.Route[0]) {
if isHorizontal && e.Src.Parent != g.Root && e.Dst.Parent != g.Root {
moveWholeEdge = true
} else {
e.Route[0].Y += stepSize
}
}
}
if !moveWholeEdge && e.Dst == curr {
if shouldMove(e.Route[len(e.Route)-1]) {
if isHorizontal && e.Dst.Parent != g.Root && e.Src.Parent != g.Root {
moveWholeEdge = true
} else {
e.Route[len(e.Route)-1].Y += stepSize
}
}
}
if moveWholeEdge {
for _, p := range e.Route {
p.Y += stepSize / 2.
}
movedEdges[e] = struct{}{}
}
}
q = append(q, curr.ChildrenArray...)
}
}
for _, edge := range g.Edges {
points := edge.Route
startIndex, endIndex := 0, len(points)-1
start, end := points[startIndex], points[endIndex]
// arrowheads can appear broken if segments are very short from dagre routing a point just outside the shape
// to fix this, we try extending the previous segment into the shape instead of having a very short segment
@ -295,7 +437,36 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
// trace the edge to the specific shape's border
points[startIndex] = shape.TraceToShapeBorder(srcShape, start, points[startIndex+1])
points[endIndex] = shape.TraceToShapeBorder(dstShape, end, points[endIndex-1])
// if an edge to a container runs into its label, stop the edge at the label instead
overlapsContainerLabel := false
if edge.Dst.IsContainer() && edge.Dst.Attributes.Label.Value != "" {
// assumes LabelPosition, LabelWidth, LabelHeight are all set if there is a label
labelWidth := float64(*edge.Dst.LabelWidth)
labelHeight := float64(*edge.Dst.LabelHeight)
labelTL := label.Position(*edge.Dst.LabelPosition).
GetPointOnBox(edge.Dst.Box, label.PADDING, labelWidth, labelHeight)
endingSegment := geo.Segment{Start: points[endIndex-1], End: points[endIndex]}
labelBox := geo.NewBox(labelTL, labelWidth, labelHeight)
// add left/right padding to box
labelBox.TopLeft.X -= label.PADDING
labelBox.Width += 2 * label.PADDING
if intersections := labelBox.Intersections(endingSegment); len(intersections) > 0 {
overlapsContainerLabel = true
// move ending segment to label intersection point
points[endIndex] = intersections[0]
endingSegment.End = intersections[0]
// if the segment becomes too short, just merge it with the previous segment
if endIndex-1 > 0 && endingSegment.Length() < MIN_SEGMENT_LEN {
points[endIndex-1] = points[endIndex]
endIndex--
}
}
}
if !overlapsContainerLabel {
points[endIndex] = shape.TraceToShapeBorder(dstShape, end, points[endIndex-1])
}
points = points[startIndex : endIndex+1]
// build a curved path from the dagre route

View file

@ -1,4 +1,5 @@
elk.js comes from https://github.com/kieler/elkjs
Currently on v0.8.2
Attribution:

View file

@ -9,6 +9,7 @@ import (
_ "embed"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/dop251/goja"
@ -87,9 +88,9 @@ type ConfigurableOpts struct {
var DefaultOpts = ConfigurableOpts{
Algorithm: "layered",
NodeSpacing: 100.0,
Padding: "[top=75,left=75,bottom=75,right=75]",
EdgeNodeSpacing: 50.0,
NodeSpacing: 70.0,
Padding: "[top=50,left=50,bottom=50,right=50]",
EdgeNodeSpacing: 40.0,
SelfLoopSpacing: 50.0,
}
@ -102,6 +103,9 @@ type elkOpts struct {
ForceNodeModelOrder bool `json:"elk.layered.crossingMinimization.forceNodeModelOrder,omitempty"`
ConsiderModelOrder string `json:"elk.layered.considerModelOrder.strategy,omitempty"`
NodeSizeConstraints string `json:"elk.nodeSize.constraints,omitempty"`
NodeSizeMinimum string `json:"elk.nodeSize.minimum,omitempty"`
ConfigurableOpts
}
@ -132,7 +136,7 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
elkGraph := &ELKGraph{
ID: "root",
LayoutOptions: &elkOpts{
Thoroughness: 20,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
HierarchyHandling: "INCLUDE_CHILDREN",
ConsiderModelOrder: "NODES_AND_EDGES",
@ -173,25 +177,29 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
walk(g.Root, nil, func(obj, parent *d2graph.Object) {
height := obj.Height
width := obj.Width
if obj.LabelWidth != nil && obj.LabelHeight != nil {
if obj.Attributes.Shape.Value == d2target.ShapeImage || obj.Attributes.Icon != nil {
height += float64(*obj.LabelHeight) + label.PADDING
}
width = go2.Max(width, float64(*obj.LabelWidth))
}
n := &ELKNode{
ID: obj.AbsID(),
Width: obj.Width,
Width: width,
Height: height,
}
if len(obj.ChildrenArray) > 0 {
n.LayoutOptions = &elkOpts{
ForceNodeModelOrder: true,
Thoroughness: 20,
Thoroughness: 8,
EdgeEdgeBetweenLayersSpacing: 50,
HierarchyHandling: "INCLUDE_CHILDREN",
ConsiderModelOrder: "NODES_AND_EDGES",
// Why is it (height, width)? I have no clue, but it works.
NodeSizeMinimum: fmt.Sprintf("(%d, %d)", int(math.Ceil(height)), int(math.Ceil(width))),
ConfigurableOpts: ConfigurableOpts{
NodeSpacing: opts.NodeSpacing,
EdgeNodeSpacing: opts.EdgeNodeSpacing,
@ -199,6 +207,30 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
Padding: opts.Padding,
},
}
// Only set if specified.
// There's a bug where if it's the node label dimensions that set the NodeSizeMinimum,
// then suddenly it's reversed back to (width, height). I must be missing something
if obj.Attributes.Width != nil || obj.Attributes.Height != nil {
n.LayoutOptions.NodeSizeConstraints = "MINIMUM_SIZE"
}
if n.LayoutOptions.Padding == DefaultOpts.Padding {
// Default
paddingTop := 50
if obj.LabelHeight != nil {
paddingTop = go2.Max(paddingTop, *obj.LabelHeight+label.PADDING)
}
if obj.Attributes.Icon != nil && obj.Attributes.Shape.Value != d2target.ShapeImage {
contentBox := geo.NewBox(geo.NewPoint(0, 0), float64(n.Width), float64(n.Height))
shapeType := d2target.DSL_SHAPE_TO_SHAPE_TYPE[obj.Attributes.Shape.Value]
s := shape.NewShape(shapeType, contentBox)
iconSize := d2target.GetIconSize(s.GetInnerBox(), string(label.InsideTopLeft))
paddingTop = go2.Max(paddingTop, iconSize+label.PADDING*2)
}
n.LayoutOptions.Padding = fmt.Sprintf("[top=%d,left=50,bottom=50,right=50]",
paddingTop,
)
}
}
if obj.LabelWidth != nil && obj.LabelHeight != nil {
@ -310,7 +342,12 @@ func Layout(ctx context.Context, g *d2graph.Graph, opts *ConfigurableOpts) (err
}
}
if obj.Attributes.Icon != nil {
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
if len(obj.ChildrenArray) > 0 {
obj.IconPosition = go2.Pointer(string(label.InsideTopLeft))
obj.LabelPosition = go2.Pointer(string(label.InsideTopRight))
} else {
obj.IconPosition = go2.Pointer(string(label.InsideMiddleCenter))
}
}
byID[obj.AbsID()] = obj

View file

@ -0,0 +1,11 @@
package d2layoutfeatures
// When this is true, objects can set ther `near` key to another object
// When this is false, objects can only set `near` to constants
const NEAR_OBJECT = "near_object"
// When this is true, containers can have dimensions set
const CONTAINER_DIMENSIONS = "container_dimensions"
// When this is true, objects can specify their `top` and `left` keywords
const TOP_LEFT = "top_left"

View file

@ -98,7 +98,7 @@ func WithoutConstantNears(ctx context.Context, g *d2graph.Graph) (nears []*d2gra
nears = append(nears, obj)
g.Objects = append(g.Objects[:i], g.Objects[i+1:]...)
i--
delete(obj.Parent.Children, obj.ID)
delete(obj.Parent.Children, strings.ToLower(obj.ID))
for i := 0; i < len(obj.Parent.ChildrenArray); i++ {
if obj.Parent.ChildrenArray[i] == obj {
obj.Parent.ChildrenArray = append(obj.Parent.ChildrenArray[:i], obj.Parent.ChildrenArray[i+1:]...)

View file

@ -1,21 +1,24 @@
package d2sequence
// leaves at least 25 units of space on the left/right when computing the space required between actors
const HORIZONTAL_PAD = 50.
// units of space on the left/right when computing the space required between actors
const HORIZONTAL_PAD = 40.
// leaves at least 25 units of space on the top/bottom when computing the space required between messages
const VERTICAL_PAD = 50.
// units of space on the top/bottom when computing the space required between messages
// TODO lower
const VERTICAL_PAD = 40.
const MIN_ACTOR_DISTANCE = 250.
const MIN_ACTOR_DISTANCE = 150.
const MIN_ACTOR_WIDTH = 150.
const MIN_ACTOR_WIDTH = 100.
const SELF_MESSAGE_HORIZONTAL_TRAVEL = 100.
const SELF_MESSAGE_HORIZONTAL_TRAVEL = 80.
const GROUP_CONTAINER_PADDING = 24.
const GROUP_CONTAINER_PADDING = 12.
const EDGE_GROUP_LABEL_PADDING = 20.
// min vertical distance between messages
const MIN_MESSAGE_DISTANCE = 80.
const MIN_MESSAGE_DISTANCE = 30.
// default size
const SPAN_BASE_WIDTH = 12.
@ -24,9 +27,9 @@ const SPAN_BASE_WIDTH = 12.
const SPAN_DEPTH_GROWTH_FACTOR = 8.
// when a span has a single messages
const MIN_SPAN_HEIGHT = 80.
const MIN_SPAN_HEIGHT = 30.
const SPAN_MESSAGE_PAD = 16.
const SPAN_MESSAGE_PAD = 10.
const LIFELINE_STROKE_WIDTH int = 2

View file

@ -106,8 +106,11 @@ func layoutSequenceDiagram(g *d2graph.Graph, obj *d2graph.Object) (*sequenceDiag
}
}
sd := newSequenceDiagram(obj.ChildrenArray, edges)
err := sd.layout()
sd, err := newSequenceDiagram(obj.ChildrenArray, edges)
if err != nil {
return nil, err
}
err = sd.layout()
return sd, err
}

View file

@ -407,8 +407,8 @@ func TestSelfEdges(t *testing.T) {
t.Fatalf("route does not end at the same actor, start at %.5f, end at %.5f", route[0].X, route[3].X)
}
if route[3].Y-route[0].Y != d2sequence.MIN_MESSAGE_DISTANCE {
t.Fatalf("expected route height to be %.f5, got %.5f", d2sequence.MIN_MESSAGE_DISTANCE, route[3].Y-route[0].Y)
if route[3].Y-route[0].Y != d2sequence.MIN_MESSAGE_DISTANCE*1.5 {
t.Fatalf("expected route height to be %.5f, got %.5f", d2sequence.MIN_MESSAGE_DISTANCE*1.5, route[3].Y-route[0].Y)
}
}

View file

@ -1,6 +1,7 @@
package d2sequence
import (
"errors"
"fmt"
"math"
"sort"
@ -64,7 +65,7 @@ func getEdgeEarliestLineNum(e *d2graph.Edge) int {
return min
}
func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *sequenceDiagram {
func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) (*sequenceDiagram, error) {
var actors []*d2graph.Object
var groups []*d2graph.Object
@ -84,6 +85,10 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
}
}
if len(actors) == 0 {
return nil, errors.New("no actors declared in sequence diagram")
}
sd := &sequenceDiagram{
messages: messages,
actors: actors,
@ -107,7 +112,7 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
if actor.Width < MIN_ACTOR_WIDTH {
dslShape := strings.ToLower(actor.Attributes.Shape.Value)
switch dslShape {
case d2target.ShapePerson, d2target.ShapeSquare, d2target.ShapeCircle:
case d2target.ShapePerson, d2target.ShapeOval, d2target.ShapeSquare, d2target.ShapeCircle:
// scale shape up to min width uniformly
actor.Height *= MIN_ACTOR_WIDTH / actor.Width
}
@ -156,6 +161,7 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
for _, message := range sd.messages {
sd.verticalIndices[message.AbsID()] = getEdgeEarliestLineNum(message)
// TODO this should not be global yStep, only affect the neighbors
sd.yStep = math.Max(sd.yStep, float64(message.LabelDimensions.Height))
// ensures that long labels, spanning over multiple actors, don't make for large gaps between actors
@ -176,7 +182,6 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
if _, exists := sd.firstMessage[message.Dst]; !exists {
sd.firstMessage[message.Dst] = message
}
}
sd.yStep += VERTICAL_PAD
@ -185,7 +190,7 @@ func newSequenceDiagram(objects []*d2graph.Object, messages []*d2graph.Edge) *se
sd.maxActorHeight += float64(*sd.root.LabelHeight)
}
return sd
return sd, nil
}
func (sd *sequenceDiagram) layout() error {
@ -209,6 +214,9 @@ func (sd *sequenceDiagram) placeGroups() {
group.ZIndex = GROUP_Z_INDEX
sd.placeGroup(group)
}
for _, group := range sd.groups {
sd.adjustGroupLabel(group)
}
}
func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
@ -246,8 +254,8 @@ func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
if inGroup {
minX = math.Min(minX, n.TopLeft.X-HORIZONTAL_PAD)
minY = math.Min(minY, n.TopLeft.Y-MIN_MESSAGE_DISTANCE/2.)
maxY = math.Max(maxY, n.TopLeft.Y+n.Height+HORIZONTAL_PAD)
maxX = math.Max(maxX, n.TopLeft.X+n.Width+MIN_MESSAGE_DISTANCE/2.)
maxX = math.Max(maxX, n.TopLeft.X+n.Width+HORIZONTAL_PAD)
maxY = math.Max(maxY, n.TopLeft.Y+n.Height+MIN_MESSAGE_DISTANCE/2.)
}
}
@ -273,6 +281,56 @@ func (sd *sequenceDiagram) placeGroup(group *d2graph.Object) {
)
}
func (sd *sequenceDiagram) adjustGroupLabel(group *d2graph.Object) {
if group.LabelHeight == nil {
return
}
heightAdd := (*group.LabelHeight + EDGE_GROUP_LABEL_PADDING) - GROUP_CONTAINER_PADDING
if heightAdd < 0 {
return
}
group.Height += float64(heightAdd)
// Extend stuff within this group
for _, g := range sd.groups {
if g.TopLeft.Y < group.TopLeft.Y && g.TopLeft.Y+g.Height > group.TopLeft.Y {
g.Height += float64(heightAdd)
}
}
for _, s := range sd.spans {
if s.TopLeft.Y < group.TopLeft.Y && s.TopLeft.Y+s.Height > group.TopLeft.Y {
s.Height += float64(heightAdd)
}
}
// Move stuff down
for _, m := range sd.messages {
if go2.Min(m.Route[0].Y, m.Route[len(m.Route)-1].Y) > group.TopLeft.Y {
for _, p := range m.Route {
p.Y += float64(heightAdd)
}
}
}
for _, s := range sd.spans {
if s.TopLeft.Y > group.TopLeft.Y {
s.TopLeft.Y += float64(heightAdd)
}
}
for _, g := range sd.groups {
if g.TopLeft.Y > group.TopLeft.Y {
g.TopLeft.Y += float64(heightAdd)
}
}
for _, n := range sd.notes {
if n.TopLeft.Y > group.TopLeft.Y {
n.TopLeft.Y += float64(heightAdd)
}
}
}
// placeActors places actors bottom aligned, side by side with centers spaced by sd.actorXStep
func (sd *sequenceDiagram) placeActors() {
centerX := sd.actors[0].Width / 2.
@ -354,7 +412,7 @@ func (sd *sequenceDiagram) placeNotes() {
rankToX[sd.objectRank[actor]] = actor.Center().X
}
for i, note := range sd.notes {
for _, note := range sd.notes {
verticalIndex := sd.verticalIndices[note.AbsID()]
y := sd.maxActorHeight + sd.yStep
@ -363,8 +421,10 @@ func (sd *sequenceDiagram) placeNotes() {
y += sd.yStep
}
}
for _, otherNote := range sd.notes[:i] {
y += otherNote.Height + sd.yStep
for _, otherNote := range sd.notes {
if sd.verticalIndices[otherNote.AbsID()] < verticalIndex {
y += otherNote.Height + sd.yStep
}
}
x := rankToX[sd.objectRank[note]] - (note.Width / 2.)
@ -476,12 +536,12 @@ func (sd *sequenceDiagram) routeMessages() error {
if startCenter := getCenter(message.Src); startCenter != nil {
startX = startCenter.X
} else {
return fmt.Errorf("could not find center of %s", message.Src.AbsID())
return fmt.Errorf("could not find center of %s. Is it declared as an actor?", message.Src.ID)
}
if endCenter := getCenter(message.Dst); endCenter != nil {
endX = endCenter.X
} else {
return fmt.Errorf("could not find center of %s", message.Dst.AbsID())
return fmt.Errorf("could not find center of %s. Is it declared as an actor?", message.Dst.ID)
}
isToDescendant := strings.HasPrefix(message.Dst.AbsID(), message.Src.AbsID()+".")
isFromDescendant := strings.HasPrefix(message.Src.AbsID(), message.Dst.AbsID()+".")
@ -499,7 +559,7 @@ func (sd *sequenceDiagram) routeMessages() error {
if isSelfMessage || isToDescendant || isFromDescendant || isToSibling {
midX := startX + SELF_MESSAGE_HORIZONTAL_TRAVEL
endY := startY + MIN_MESSAGE_DISTANCE
endY := startY + MIN_MESSAGE_DISTANCE*1.5
message.Route = []*geo.Point{
geo.NewPoint(startX, startY),
geo.NewPoint(midX, startY),
@ -526,7 +586,7 @@ func (sd *sequenceDiagram) routeMessages() error {
func getCenter(obj *d2graph.Object) *geo.Point {
if obj == nil {
return nil
} else if obj.TopLeft != nil {
} else if obj.Box != nil && obj.Box.TopLeft != nil {
return obj.Center()
}
return getCenter(obj.Parent)

View file

@ -29,7 +29,6 @@ type CompileOptions struct {
// - pre-measured (web setting)
// TODO maybe some will want to configure code font too, but that's much lower priority
FontFamily *d2fonts.FontFamily
ThemeID int64
}
func Compile(ctx context.Context, input string, opts *CompileOptions) (*d2target.Diagram, *d2graph.Graph, error) {
@ -76,7 +75,7 @@ func compile(ctx context.Context, g *d2graph.Graph, opts *CompileOptions) (*d2ta
}
}
d, err := d2exporter.Export(ctx, g, opts.ThemeID, opts.FontFamily)
d, err := d2exporter.Export(ctx, g, opts.FontFamily)
if err != nil {
return nil, err
}

View file

@ -111,6 +111,25 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error {
toSkip := 1
reserved := false
// If you're setting `(x -> y)[0].style.opacity`
// There's 3 cases you need to handle:
// 1. The edge has no map.
// 2. The edge has a style map with opacity not existing
// 3. The edge has a style map with opacity existing
//
// How each case is handled:
// 1. Append that mapkey to edge.
// 2. Append opacity to the style map
// 3. Set opacity
//
// There's certainly cleaner code to achieve this, but currently, there's a lot of logic to correctly scope, merge, append.
// The tests should be comprehensive enough for a safe refactor someday
//
// reservedKey = "style"
// reservedTargetKey = "opacity"
reservedKey := ""
reservedTargetKey := ""
if mk.Key != nil {
found := true
for _, idel := range d2graph.Key(mk.Key) {
@ -162,12 +181,14 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error {
}
attrs := obj.Attributes
var edge *d2graph.Edge
if len(mk.Edges) == 1 {
if mk.EdgeIndex == nil {
appendMapKey(scope, mk)
return nil
}
edge, ok := obj.HasEdge(mk)
var ok bool
edge, ok = obj.HasEdge(mk)
if !ok {
return errors.New("edge not found")
}
@ -179,7 +200,17 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error {
// (y -> z)[0].style.animated: true
if len(ref.MapKey.Edges) == 1 && ref.MapKey.EdgeIndex == nil {
onlyInChain = false
break
}
// If a ref has an exact match on this key, just change the value
tmp1 := *ref.MapKey
tmp2 := *mk
noVal1 := &tmp1
noVal2 := &tmp2
noVal1.Value = d2ast.ValueBox{}
noVal2.Value = d2ast.ValueBox{}
if noVal1.Equals(noVal2) {
ref.MapKey.Value = mk.Value
return nil
}
}
if onlyInChain {
@ -206,10 +237,26 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error {
if ref.MapKey.Value.Map != nil {
foundMap = true
scope = ref.MapKey.Value.Map
// TODO when edges can have more fields, search for style
if len(scope.Nodes) == 1 && scope.Nodes[0].MapKey.Value.Map != nil {
scope = scope.Nodes[0].MapKey.Value.Map
mk.Key.Path = mk.Key.Path[1:]
for _, n := range scope.Nodes {
if n.MapKey.Value.Map == nil {
continue
}
if n.MapKey.Key == nil || len(n.MapKey.Key.Path) != 1 {
continue
}
if n.MapKey.Key.Path[0].Unbox().ScalarString() == mk.Key.Path[toSkip-1].Unbox().ScalarString() {
scope = n.MapKey.Value.Map
if mk.Key.Path[0].Unbox().ScalarString() == "source-arrowhead" && edge.SrcArrowhead != nil {
attrs = edge.SrcArrowhead
}
if mk.Key.Path[0].Unbox().ScalarString() == "target-arrowhead" && edge.DstArrowhead != nil {
attrs = edge.DstArrowhead
}
reservedKey = mk.Key.Path[0].Unbox().ScalarString()
mk.Key.Path = mk.Key.Path[1:]
reservedTargetKey = mk.Key.Path[0].Unbox().ScalarString()
break
}
}
break
}
@ -228,17 +275,79 @@ func _set(g *d2graph.Graph, key string, tag, value *string) error {
if reserved {
reservedIndex := toSkip - 1
if mk.Key != nil && len(mk.Key.Path) > 0 {
switch mk.Key.Path[reservedIndex].Unbox().ScalarString() {
if reservedKey == "" {
reservedKey = mk.Key.Path[reservedIndex].Unbox().ScalarString()
}
switch reservedKey {
case "shape":
if attrs.Shape.MapKey != nil {
attrs.Shape.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "style":
if len(mk.Key.Path[reservedIndex:]) != 2 {
return errors.New("malformed style setting, expected 2 part path")
case "link":
if attrs.Link != nil && attrs.Link.MapKey != nil {
attrs.Link.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
switch mk.Key.Path[reservedIndex+1].Unbox().ScalarString() {
case "tooltip":
if attrs.Tooltip != nil && attrs.Tooltip.MapKey != nil {
attrs.Tooltip.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "width":
if attrs.Width != nil && attrs.Width.MapKey != nil {
attrs.Width.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "height":
if attrs.Height != nil && attrs.Height.MapKey != nil {
attrs.Height.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "top":
if attrs.Top != nil && attrs.Top.MapKey != nil {
attrs.Top.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "left":
if attrs.Left != nil && attrs.Left.MapKey != nil {
attrs.Left.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "source-arrowhead", "target-arrowhead":
if reservedKey == "source-arrowhead" {
attrs = edge.SrcArrowhead
} else {
attrs = edge.DstArrowhead
}
if attrs != nil {
if reservedTargetKey == "" {
if len(mk.Key.Path[reservedIndex:]) != 2 {
return errors.New("malformed style setting, expected 2 part path")
}
reservedTargetKey = mk.Key.Path[reservedIndex+1].Unbox().ScalarString()
}
switch reservedTargetKey {
case "shape":
if attrs.Shape.MapKey != nil {
attrs.Shape.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
case "label":
if attrs.Label.MapKey != nil {
attrs.Label.MapKey.SetScalar(mk.Value.ScalarBox())
return nil
}
}
}
case "style":
if reservedTargetKey == "" {
if len(mk.Key.Path[reservedIndex:]) != 2 {
return errors.New("malformed style setting, expected 2 part path")
}
reservedTargetKey = mk.Key.Path[reservedIndex+1].Unbox().ScalarString()
}
switch reservedTargetKey {
case "opacity":
if attrs.Style.Opacity != nil {
attrs.Style.Opacity.MapKey.SetScalar(mk.Value.ScalarBox())
@ -667,6 +776,10 @@ func deleteReserved(g *d2graph.Graph, mk *d2ast.Key) (*d2graph.Graph, error) {
if id == "near" ||
id == "tooltip" ||
id == "icon" ||
id == "width" ||
id == "height" ||
id == "left" ||
id == "top" ||
id == "link" {
err := deleteObjField(g, obj, id)
if err != nil {
@ -690,7 +803,9 @@ func deleteMapField(m *d2ast.Map, field string) {
if n.MapKey != nil && n.MapKey.Key != nil {
if n.MapKey.Key.Path[0].Unbox().ScalarString() == field {
deleteFromMap(m, n.MapKey)
} else if n.MapKey.Key.Path[0].Unbox().ScalarString() == "style" {
} else if n.MapKey.Key.Path[0].Unbox().ScalarString() == "style" ||
n.MapKey.Key.Path[0].Unbox().ScalarString() == "source-arrowhead" ||
n.MapKey.Key.Path[0].Unbox().ScalarString() == "target-arrowhead" {
if n.MapKey.Value.Map != nil {
deleteMapField(n.MapKey.Value.Map, field)
if len(n.MapKey.Value.Map.Nodes) == 0 {
@ -773,13 +888,15 @@ func deleteObject(g *d2graph.Graph, key *d2ast.KeyPath, obj *d2graph.Object) (*d
if len(ref.MapKey.Edges) == 0 {
isSuffix := ref.KeyPathIndex == len(ref.Key.Path)-1
ref.Key.Path = append(ref.Key.Path[:ref.KeyPathIndex], ref.Key.Path[ref.KeyPathIndex+1:]...)
withoutReserved := go2.Filter(ref.Key.Path, func(x *d2ast.StringBox) bool {
_, ok := d2graph.ReservedKeywords[x.Unbox().ScalarString()]
return !ok
withoutSpecial := go2.Filter(ref.Key.Path, func(x *d2ast.StringBox) bool {
_, isReserved := d2graph.ReservedKeywords[x.Unbox().ScalarString()]
isSpecial := isReserved || x.Unbox().ScalarString() == "_"
return !isSpecial
})
if obj.Attributes.Shape.Value == d2target.ShapeSQLTable || obj.Attributes.Shape.Value == d2target.ShapeClass {
ref.MapKey.Value.Map = nil
} else if len(withoutReserved) == 0 {
ref.MapKey.Primary = ref.MapKey.Value.ScalarBox()
} else if len(withoutSpecial) == 0 {
hoistRefChildren(g, key, ref)
deleteFromMap(ref.Scope, ref.MapKey)
} else if ref.MapKey.Value.Unbox() == nil &&
@ -1167,7 +1284,7 @@ func move(g *d2graph.Graph, key, newKey string) (*d2graph.Graph, error) {
}
ida := d2graph.Key(ref.Key)
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, obj)
resolvedObj, resolvedIDA, err := d2graph.ResolveUnderscoreKey(ida, ref.ScopeObj)
if err != nil {
return nil, err
}
@ -1422,7 +1539,7 @@ func updateNear(prevG, g *d2graph.Graph, from, to *string) error {
if len(n.MapKey.Key.Path) == 0 {
continue
}
if n.MapKey.Key.Path[0].Unbox().ScalarString() == "near" {
if n.MapKey.Key.Path[len(n.MapKey.Key.Path)-1].Unbox().ScalarString() == "near" {
k := n.MapKey.Value.ScalarBox().Unbox().ScalarString()
if strings.EqualFold(k, *from) && to == nil {
deleteFromMap(obj.Map, n.MapKey)
@ -1839,6 +1956,12 @@ func DeleteIDDeltas(g *d2graph.Graph, key string) (deltas map[string]string, err
conflictNewIDs := make(map[*d2graph.Object]string)
conflictOldIDs := make(map[*d2graph.Object]string)
if mk.Key != nil {
ida := d2graph.Key(mk.Key)
// Deleting a reserved field cannot possibly have any deltas
if _, ok := d2graph.ReservedKeywords[ida[len(ida)-1]]; ok {
return nil, nil
}
var ok bool
obj, ok = g.Root.HasChild(d2graph.Key(mk.Key))
if !ok {
@ -2066,7 +2189,13 @@ func hasSpace(tag string) bool {
}
func getMostNestedRefs(obj *d2graph.Object) []d2graph.Reference {
most := obj.References[0]
var most d2graph.Reference
for _, ref := range obj.References {
if len(ref.MapKey.Edges) == 0 {
most = ref
break
}
}
for _, ref := range obj.References {
if len(ref.MapKey.Edges) != 0 {
continue
@ -2080,11 +2209,11 @@ func getMostNestedRefs(obj *d2graph.Object) []d2graph.Reference {
if err != nil {
mostKey = &d2ast.KeyPath{}
}
_, resolvedScopeKey, err := d2graph.ResolveUnderscoreKey(d2graph.Key(scopeKey), obj)
_, resolvedScopeKey, err := d2graph.ResolveUnderscoreKey(d2graph.Key(scopeKey), ref.ScopeObj)
if err != nil {
continue
}
_, resolvedMostKey, err := d2graph.ResolveUnderscoreKey(d2graph.Key(mostKey), obj)
_, resolvedMostKey, err := d2graph.ResolveUnderscoreKey(d2graph.Key(mostKey), ref.ScopeObj)
if err != nil {
continue
}

View file

@ -695,6 +695,151 @@ square.style.opacity: 0.2
}
},
},
{
name: "set_position",
text: `square
`,
key: `square.top`,
value: go2.Pointer(`200`),
exp: `square: {top: 200}
`,
},
{
name: "replace_position",
text: `square: {
width: 100
top: 32
left: 44
}
`,
key: `square.top`,
value: go2.Pointer(`200`),
exp: `square: {
width: 100
top: 200
left: 44
}
`,
},
{
name: "set_dimensions",
text: `square
`,
key: `square.width`,
value: go2.Pointer(`200`),
exp: `square: {width: 200}
`,
},
{
name: "replace_dimensions",
text: `square: {
width: 100
}
`,
key: `square.width`,
value: go2.Pointer(`200`),
exp: `square: {
width: 200
}
`,
},
{
name: "set_tooltip",
text: `square
`,
key: `square.tooltip`,
value: go2.Pointer(`y`),
exp: `square: {tooltip: y}
`,
},
{
name: "replace_tooltip",
text: `square: {
tooltip: x
}
`,
key: `square.tooltip`,
value: go2.Pointer(`y`),
exp: `square: {
tooltip: y
}
`,
},
{
name: "replace_link",
text: `square: {
link: https://google.com
}
`,
key: `square.link`,
value: go2.Pointer(`https://apple.com`),
exp: `square: {
link: https://apple.com
}
`,
},
{
name: "replace_arrowhead",
text: `x -> y: {
target-arrowhead.shape: diamond
}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`circle`),
exp: `x -> y: {
target-arrowhead.shape: circle
}
`,
},
{
name: "replace_arrowhead_map",
text: `x -> y: {
target-arrowhead: {
shape: diamond
}
}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`circle`),
exp: `x -> y: {
target-arrowhead: {
shape: circle
}
}
`,
},
{
name: "replace_edge_style_map",
text: `x -> y: {
style: {
stroke-dash: 3
}
}
`,
key: `(x -> y)[0].style.stroke-dash`,
value: go2.Pointer(`4`),
exp: `x -> y: {
style: {
stroke-dash: 4
}
}
`,
},
{
name: "replace_edge_style",
text: `x -> y: {
style.stroke-width: 1
style.stroke-dash: 4
}
`,
key: `(x -> y)[0].style.stroke-dash`,
value: go2.Pointer(`3`),
exp: `x -> y: {
style.stroke-width: 1
style.stroke-dash: 3
}
`,
},
{
name: "label_unset",
text: `square: "Always try to do things in chronological order; it's less confusing that way."
@ -1024,6 +1169,57 @@ a.b -> a.c: {style.animated: true}
value: go2.Pointer(`true`),
exp: `x -> y: {style.animated: true}
`,
},
{
name: "edge_set_arrowhead",
text: `x -> y
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`diamond`),
exp: `x -> y: {target-arrowhead.shape: diamond}
`,
},
{
name: "edge_replace_arrowhead",
text: `x -> y: {target-arrowhead.shape: circle}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`diamond`),
exp: `x -> y: {target-arrowhead.shape: diamond}
`,
},
{
name: "edge_replace_arrowhead_indexed",
text: `x -> y
(x -> y)[0].target-arrowhead.shape: circle
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`diamond`),
exp: `x -> y
(x -> y)[0].target-arrowhead.shape: diamond
`,
},
{
name: "edge_merge_arrowhead",
text: `x -> y: {
target-arrowhead: {
label: 1
}
}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`diamond`),
exp: `x -> y: {
target-arrowhead: {
label: 1
shape: diamond
}
}
`,
},
{
@ -1043,6 +1239,30 @@ a.b -> a.c: {style.animated: true}
animated: true
}
}
`,
},
{
name: "edge_flat_merge_arrowhead",
text: `x -> y -> z
(x -> y)[0].target-arrowhead.shape: diamond
`,
key: `(x -> y)[0].target-arrowhead.shape`,
value: go2.Pointer(`circle`),
exp: `x -> y -> z
(x -> y)[0].target-arrowhead.shape: circle
`,
},
{
name: "edge_index_merge_style",
text: `x -> y -> z
(x -> y)[0].style.opacity: 0.4
`,
key: `(x -> y)[0].style.opacity`,
value: go2.Pointer(`0.5`),
exp: `x -> y -> z
(x -> y)[0].style.opacity: 0.5
`,
},
{
@ -1755,6 +1975,20 @@ b
assert.JSON(t, 0, len(g.Objects[0].Children))
},
},
{
name: "out_of_newline_container",
text: `"a\n": {
b
}
`,
key: `"a\n".b`,
newKey: `b`,
exp: `"a\n"
b
`,
},
{
name: "partial_slice",
@ -1987,6 +2221,50 @@ c: {
assert.JSON(t, len(g.Objects), 3)
},
},
{
name: "underscore-connection",
text: `a: {
b
_.c.d -> b
}
c: {
d
}
`,
key: `a.b`,
newKey: `c.b`,
exp: `a: {
_.c.d -> _.c.b
}
c: {
d
b
}
`,
},
{
name: "nested-underscore-move-out",
text: `guitar: {
books: {
_._.pipe
}
}
`,
key: `pipe`,
newKey: `guitar.pipe`,
exp: `guitar: {
books
pipe
}
`,
},
{
name: "flat_middle_container",
@ -2162,7 +2440,7 @@ a.b.c: {
`,
},
{
name: "near",
name: "invalid-near",
text: `x: {
near: y
@ -2176,6 +2454,33 @@ y
near: x.y
y
}
`,
expErr: `failed to move: "y" to "x.y": failed to recompile:
x: {
near: x.y
y
}
d2/testdata/d2oracle/TestMove/invalid-near.d2:2:9: near keys cannot be set to an descendant`,
},
{
name: "near",
text: `x: {
near: y
}
a
y
`,
key: `y`,
newKey: `a.y`,
exp: `x: {
near: a.y
}
a: {
y
}
`,
},
{
@ -3004,6 +3309,22 @@ d
exp: `a: {
_.b -> c -> _.b
}
`,
},
{
name: "container_multiple_refs_with_underscore",
text: `a
b: {
_.a
}
`,
key: `a`,
newKey: `b.a`,
exp: `b: {
a
}
`,
},
}
@ -3149,6 +3470,41 @@ c -> d
exp: `books: {
_.pipe
}
`,
},
{
name: "only-underscore",
text: `guitar: {
books: {
_._.pipe
}
}
`,
key: `pipe`,
exp: `guitar: {
books
}
`,
},
{
name: "only-underscore-nested",
text: `guitar: {
books: {
_._.pipe: {
a
}
}
}
`,
key: `pipe`,
exp: `guitar: {
books
}
a
`,
},
{
@ -3291,6 +3647,71 @@ x
key: `x`,
exp: `y
`,
},
{
name: "arrowhead",
text: `x -> y: {
target-arrowhead.shape: diamond
}
`,
key: `(x -> y)[0].target-arrowhead`,
exp: `x -> y
`,
},
{
name: "arrowhead_shape",
text: `x -> y: {
target-arrowhead.shape: diamond
}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
exp: `x -> y
`,
},
{
name: "arrowhead_label",
text: `x -> y: {
target-arrowhead.shape: diamond
target-arrowhead.label: 1
}
`,
key: `(x -> y)[0].target-arrowhead.label`,
exp: `x -> y: {
target-arrowhead.shape: diamond
}
`,
},
{
name: "arrowhead_map",
text: `x -> y: {
target-arrowhead: {
shape: diamond
}
}
`,
key: `(x -> y)[0].target-arrowhead.shape`,
exp: `x -> y
`,
},
{
name: "edge-only-style",
text: `x -> y: {
style.stroke: red
}
`,
key: `(x -> y)[0].style.stroke`,
exp: `x -> y
`,
},
{
@ -3419,6 +3840,37 @@ y
exp: `x
y
`,
},
{
name: "delete_container_of_near",
text: `direction: down
first input -> start game -> game loop
game loop: {
direction: down
input -> increase bird top velocity
move bird -> move pipes -> render
render -> no collision -> wait 16 milliseconds -> move bird
render -> collision detected -> game over
no collision.near: game loop.collision detected
}
`,
key: `game loop`,
exp: `direction: down
first input -> start game
input -> increase bird top velocity
move bird -> move pipes -> render
render -> no collision -> wait 16 milliseconds -> move bird
render -> collision detected -> game over
no collision.near: collision detected
`,
},
{
@ -4339,6 +4791,64 @@ A -> B: {style.stroke: "#2b50c2"}
exp: `A: {style.stroke: "#000e3d"}
B
A -> B
`,
},
{
name: "width",
text: `x: {
width: 200
}
`,
key: `x.width`,
exp: `x
`,
},
{
name: "left",
text: `x: {
left: 200
}
`,
key: `x.left`,
exp: `x
`,
},
{
name: "chaos_1",
text: `cm: {shape: cylinder}
cm <-> cm: {source-arrowhead.shape: cf-one-required}
mt: z
cdpdxz
bymdyk: hdzuj {shape: class}
bymdyk <-> bymdyk
cm
cm <-> bymdyk: {
source-arrowhead.shape: cf-many-required
target-arrowhead.shape: arrow
}
bymdyk <-> cdpdxz
bymdyk -> cm: nk {
target-arrowhead.shape: diamond
target-arrowhead.label: 1
}
`,
key: `bymdyk`,
exp: `cm: {shape: cylinder}
cm <-> cm: {source-arrowhead.shape: cf-one-required}
mt: z
cdpdxz
cm
`,
},
}
@ -4701,6 +5211,46 @@ x.y.z.w.e.p.l -> x.y.z.1.2.3.4
exp: `{
"x.x": "x"
}`,
},
{
name: "nested-height",
text: `x: {
a -> b
height: 200
}
`,
key: `x.height`,
exp: `null`,
},
{
name: "edge-style",
text: `x <-> y: {
target-arrowhead: circle
source-arrowhead: diamond
}
`,
key: `(x <-> y)[0].target-arrowhead`,
exp: `null`,
},
{
name: "only-reserved",
text: `guitar: {
books: {
_._.pipe: {
a
}
}
}
`,
key: `pipe`,
exp: `{
"pipe.a": "a"
}`,
},
{

View file

@ -119,6 +119,9 @@ func (p *execPlugin) Info(ctx context.Context) (_ *PluginInfo, err error) {
return nil, fmt.Errorf("failed to unmarshal json: %w", err)
}
info.Type = "binary"
info.Path = p.path
p.info = &info
return &info, nil
}

View file

@ -71,11 +71,14 @@ type PluginInfo struct {
ShortHelp string `json:"shortHelp"`
LongHelp string `json:"longHelp"`
// These two are set by ListPlugins and not the plugin itself.
// Set to bundled when returning from the plugin.
// execPlugin will set to binary when used.
// bundled | binary
Type string `json:"type"`
// If Type == binary then this contains the absolute path to the binary.
Path string `json:"path"`
Features []PluginFeature `json:"features"`
}
const binaryPrefix = "d2plugin-"
@ -122,12 +125,6 @@ func ListPluginInfos(ctx context.Context, ps []Plugin) ([]*PluginInfo, error) {
if err != nil {
return nil, err
}
if ep, ok := p.(*execPlugin); ok {
info.Type = "binary"
info.Path = ep.path
} else {
info.Type = "bundled"
}
infoSlice = append(infoSlice, info)
}

View file

@ -66,6 +66,8 @@ func (p dagrePlugin) Info(ctx context.Context) (*PluginInfo, error) {
return &PluginInfo{
Name: "dagre",
Type: "bundled",
Features: []PluginFeature{},
ShortHelp: "The directed graph layout library Dagre",
LongHelp: fmt.Sprintf(`dagre is a directed graph layout library for JavaScript.
See https://d2lang.com/tour/dagre for more.

View file

@ -85,7 +85,12 @@ func (p elkPlugin) Info(ctx context.Context) (*PluginInfo, error) {
f.AddToOpts(opts)
}
return &PluginInfo{
Name: "elk",
Name: "elk",
Type: "bundled",
Features: []PluginFeature{
CONTAINER_DIMENSIONS,
DESCENDANT_EDGES,
},
ShortHelp: "Eclipse Layout Kernel (ELK) with the Layered algorithm.",
LongHelp: fmt.Sprintf(`ELK is a layout engine offered by Eclipse.
Originally written in Java, it has been ported to Javascript and cross-compiled into D2.

View file

@ -0,0 +1,74 @@
package d2plugin
import (
"fmt"
"oss.terrastruct.com/d2/d2graph"
)
type PluginFeature string
// When this is true, objects can set ther `near` key to another object
// When this is false, objects can only set `near` to constants
const NEAR_OBJECT PluginFeature = "near_object"
// When this is true, containers can have dimensions set
const CONTAINER_DIMENSIONS PluginFeature = "container_dimensions"
// When this is true, objects can specify their `top` and `left` keywords
const TOP_LEFT PluginFeature = "top_left"
// When this is true, containers can have connections to descendants
const DESCENDANT_EDGES PluginFeature = "descendant_edges"
func FeatureSupportCheck(info *PluginInfo, g *d2graph.Graph) error {
// Older version of plugin. Skip checking.
if info.Features == nil {
return nil
}
featureMap := make(map[PluginFeature]struct{}, len(info.Features))
for _, f := range info.Features {
featureMap[f] = struct{}{}
}
for _, obj := range g.Objects {
if obj.Attributes.Top != nil || obj.Attributes.Left != nil {
if _, ok := featureMap[TOP_LEFT]; !ok {
return fmt.Errorf(`Object "%s" has attribute "top" and/or "left" set, but layout engine "%s" does not support locked positions.`, obj.AbsID(), info.Name)
}
}
if (obj.Attributes.Width != nil || obj.Attributes.Height != nil) && len(obj.ChildrenArray) > 0 {
if _, ok := featureMap[CONTAINER_DIMENSIONS]; !ok {
return fmt.Errorf(`Object "%s" has attribute "width" and/or "height" set, but layout engine "%s" does not support dimensions set on containers.`, obj.AbsID(), info.Name)
}
}
if obj.Attributes.NearKey != nil {
_, isKey := g.Root.HasChild(d2graph.Key(obj.Attributes.NearKey))
if isKey {
if _, ok := featureMap[NEAR_OBJECT]; !ok {
return fmt.Errorf(`Object "%s" has "near" set to another object, but layout engine "%s" only supports constant values for "near".`, obj.AbsID(), info.Name)
}
}
}
}
if _, ok := featureMap[DESCENDANT_EDGES]; !ok {
for _, e := range g.Edges {
// descendant edges are ok in sequence diagrams
if e.Src.OuterSequenceDiagram() != nil || e.Dst.OuterSequenceDiagram() != nil {
continue
}
if !e.Src.IsContainer() && !e.Dst.IsContainer() {
continue
}
if e.Src == e.Dst {
return fmt.Errorf(`Connection "%s" is a self loop on a container, but layout engine "%s" does not support this.`, e.AbsID(), info.Name)
}
if e.Src.IsDescendantOf(e.Dst) || e.Dst.IsDescendantOf(e.Src) {
return fmt.Errorf(`Connection "%s" goes from a container to a descendant, but layout engine "%s" does not support this.`, e.AbsID(), info.Name)
}
}
}
return nil
}

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,7 @@
package d2sketch
import (
"bytes"
"encoding/json"
"fmt"
"regexp"
@ -11,21 +12,23 @@ import (
"github.com/dop251/goja"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/svg"
"oss.terrastruct.com/util-go/go2"
)
//go:embed fillpattern.svg
var fillPattern string
//go:embed rough.js
var roughJS string
//go:embed setup.js
var setupJS string
//go:embed streaks.txt
var streaks string
type Runner goja.Runtime
var baseRoughProps = `fillWeight: 2.0,
@ -36,6 +39,11 @@ seed: 1,`
var floatRE = regexp.MustCompile(`(\d+)\.(\d+)`)
const (
BG_COLOR = color.N7
FG_COLOR = color.N1
)
func (r *Runner) run(js string) (goja.Value, error) {
vm := (*goja.Runtime)(r)
return vm.RunString(js)
@ -53,123 +61,173 @@ func InitSketchVM() (*Runner, error) {
return &r, nil
}
// DefineFillPattern adds a reusable pattern that is overlayed on shapes with
// DefineFillPatterns adds reusable patterns that are overlayed on shapes with
// fill. This gives it a subtle streaky effect that subtly looks hand-drawn but
// not distractingly so.
func DefineFillPattern() string {
return fmt.Sprintf(`<defs>
<pattern id="streaks"
x="0" y="0" width="100" height="100"
patternUnits="userSpaceOnUse" >
%s
</pattern>
</defs>`, fillPattern)
func DefineFillPatterns(buf *bytes.Buffer) {
source := buf.String()
fmt.Fprint(buf, "<defs>")
defineFillPattern(buf, source, "bright", "rgba(0, 0, 0, 0.1)")
defineFillPattern(buf, source, "normal", "rgba(0, 0, 0, 0.16)")
defineFillPattern(buf, source, "dark", "rgba(0, 0, 0, 0.32)")
defineFillPattern(buf, source, "darker", "rgba(255, 255, 255, 0.24)")
fmt.Fprint(buf, "</defs>")
}
func defineFillPattern(buf *bytes.Buffer, source string, luminanceCategory, fill string) {
trigger := fmt.Sprintf(`url(#streaks-%s)`, luminanceCategory)
if strings.Contains(source, trigger) {
fmt.Fprintf(buf, streaks, luminanceCategory, fill)
}
}
func Rect(r *Runner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%d" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height,
)
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
if err != nil {
return "", err
}
output += renderedSO
return output, nil
}
func DoubleRect(r *Runner, shape d2target.Shape) (string, error) {
jsBigRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
pathsBigRect, err := computeRoughPathData(r, jsBigRect)
if err != nil {
return "", err
}
jsSmallRect := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.StrokeWidth, baseRoughProps)
pathsSmallRect, err := computeRoughPathData(r, jsSmallRect)
if err != nil {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range pathsBigRect {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
pathEl = d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X+d2target.INNER_BORDER_OFFSET), float64(shape.Pos.Y+d2target.INNER_BORDER_OFFSET))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
// No need for inner to double paint
pathEl.Fill = "transparent"
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range pathsSmallRect {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X+d2target.INNER_BORDER_OFFSET, shape.Pos.Y+d2target.INNER_BORDER_OFFSET, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%d" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height,
)
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, shape.Fill).Render()
if err != nil {
return "", err
}
output += renderedSO
return output, nil
}
func Oval(r *Runner, shape d2target.Shape) (string, error) {
js := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
output += fmt.Sprintf(
`<ellipse class="sketch-overlay" transform="translate(%d %d)" rx="%d" ry="%d" />`,
shape.Pos.X+shape.Width/2, shape.Pos.Y+shape.Height/2, shape.Width/2, shape.Height/2,
)
soElement := d2themes.NewThemableElement("ellipse")
soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
soElement.Rx = float64(shape.Width / 2)
soElement.Ry = float64(shape.Height / 2)
renderedSO, err := d2themes.NewThemableSketchOverlay(
soElement,
pathEl.Fill,
).Render()
if err != nil {
return "", err
}
output += renderedSO
return output, nil
}
func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
jsBigCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width/2, shape.Height/2, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
jsSmallCircle := fmt.Sprintf(`node = rc.ellipse(%d, %d, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width/2, shape.Height/2, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width/2, shape.Height/2, shape.Width-d2target.INNER_BORDER_OFFSET*2, shape.Height-d2target.INNER_BORDER_OFFSET*2, shape.StrokeWidth, baseRoughProps)
pathsBigCircle, err := computeRoughPathData(r, jsBigCircle)
if err != nil {
return "", err
@ -178,23 +236,43 @@ func DoubleOval(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
output := ""
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range pathsBigCircle {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
pathEl = d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
// No need for inner to double paint
pathEl.Fill = "transparent"
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range pathsSmallCircle {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
output += fmt.Sprintf(
`<ellipse class="sketch-overlay" transform="translate(%d %d)" rx="%d" ry="%d" />`,
shape.Pos.X+shape.Width/2, shape.Pos.Y+shape.Height/2, shape.Width/2, shape.Height/2,
)
soElement := d2themes.NewThemableElement("ellipse")
soElement.SetTranslate(float64(shape.Pos.X+shape.Width/2), float64(shape.Pos.Y+shape.Height/2))
soElement.Rx = float64(shape.Width / 2)
soElement.Ry = float64(shape.Height / 2)
renderedSO, err := d2themes.NewThemableSketchOverlay(
soElement,
shape.Fill,
).Render()
if err != nil {
return "", err
}
output += renderedSO
return output, nil
}
@ -203,26 +281,35 @@ func Paths(r *Runner, shape d2target.Shape, paths []string) (string, error) {
output := ""
for _, path := range paths {
js := fmt.Sprintf(`node = rc.path("%s", {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, path, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, path, shape.StrokeWidth, baseRoughProps)
sketchPaths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range sketchPaths {
output += fmt.Sprintf(
`<path class="shape" d="%s" style="%s" />`,
p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
soElement := d2themes.NewThemableElement("path")
for _, p := range sketchPaths {
output += fmt.Sprintf(
`<path class="sketch-overlay" d="%s" />`,
p,
)
soElement.D = p
renderedSO, err := d2themes.NewThemableSketchOverlay(
soElement,
pathEl.Fill,
).Render()
if err != nil {
return "", err
}
output += renderedSO
}
}
return output, nil
@ -240,11 +327,16 @@ func Connection(r *Runner, connection d2target.Connection, path, attrs string) (
if connection.Animated {
animatedClass = " animated-connection"
}
pathEl := d2themes.NewThemableElement("path")
pathEl.Fill = color.None
pathEl.Stroke = connection.Stroke
pathEl.ClassName = fmt.Sprintf("connection%s", animatedClass)
pathEl.Style = connection.CSSStyle()
pathEl.Attributes = attrs
for _, p := range paths {
output += fmt.Sprintf(
`<path class="connection%s" fill="none" d="%s" style="%s" %s/>`,
animatedClass, p, connection.CSSStyle(), attrs,
)
pathEl.D = p
output += pathEl.Render()
}
return output, nil
}
@ -253,20 +345,23 @@ func Connection(r *Runner, connection d2target.Connection, path, attrs string) (
func Table(r *Runner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
box := geo.NewBox(
@ -278,18 +373,20 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
headerBox := geo.NewBox(box.TopLeft, box.Width, rowHeight)
js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
fill: "%s",
fill: "#000",
%s
});`, shape.Width, rowHeight, shape.Fill, baseRoughProps)
});`, shape.Width, rowHeight, baseRoughProps)
paths, err = computeRoughPathData(r, js)
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill = shape.Fill
pathEl.ClassName = "class_header"
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" transform="translate(%d %d)" d="%s" style="fill:%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.Fill,
)
pathEl.D = p
output += pathEl.Render()
}
if shape.Label != "" {
@ -300,17 +397,16 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.LabelHeight),
)
output += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text",
tl.X,
tl.Y+float64(shape.LabelHeight)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"start",
4+shape.FontSize,
shape.Stroke,
),
svg.EscapeText(shape.Label),
textEl := d2themes.NewThemableElement("text")
textEl.X = tl.X
textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
textEl.Fill = shape.GetFontColor()
textEl.ClassName = "text"
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx",
"start", 4+shape.FontSize,
)
textEl.Content = svg.EscapeText(shape.Label)
output += textEl.Render()
}
var longestNameWidth int
@ -334,26 +430,26 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.FontSize),
)
output += strings.Join([]string{
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X,
nameTL.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), shape.PrimaryAccentColor),
svg.EscapeText(f.Name.Label),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
nameTL.X+float64(longestNameWidth)+2*d2target.NamePadding,
nameTL.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", float64(shape.FontSize), shape.NeutralAccentColor),
svg.EscapeText(f.Type.Label),
),
fmt.Sprintf(`<text class="text" x="%f" y="%f" style="%s">%s</text>`,
constraintTR.X,
constraintTR.Y+float64(shape.FontSize)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;letter-spacing:2px;", "end", float64(shape.FontSize), shape.SecondaryAccentColor),
f.ConstraintAbbr(),
),
}, "\n")
textEl := d2themes.NewThemableElement("text")
textEl.X = nameTL.X
textEl.Y = nameTL.Y + float64(shape.FontSize)*3/4
textEl.Fill = shape.PrimaryAccentColor
textEl.ClassName = "text"
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "start", float64(shape.FontSize))
textEl.Content = svg.EscapeText(f.Name.Label)
output += textEl.Render()
textEl.X = nameTL.X + float64(longestNameWidth) + 2*d2target.NamePadding
textEl.Fill = shape.NeutralAccentColor
textEl.Content = svg.EscapeText(f.Type.Label)
output += textEl.Render()
textEl.X = constraintTR.X
textEl.Y = constraintTR.Y + float64(shape.FontSize)*3/4
textEl.Fill = shape.SecondaryAccentColor
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx;letter-spacing:2px", "end", float64(shape.FontSize))
textEl.Content = f.ConstraintAbbr()
output += textEl.Render()
rowBox.TopLeft.Y += rowHeight
@ -364,37 +460,47 @@ func Table(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl.Fill = shape.Fill
for _, p := range paths {
output += fmt.Sprintf(
`<path d="%s" style="fill:%s" />`,
p, shape.Fill,
)
pathEl.D = p
output += pathEl.Render()
}
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%d" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, shape.Height,
)
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = float64(shape.Height)
renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
if err != nil {
return "", err
}
output += renderedSO
return output, nil
}
func Class(r *Runner, shape d2target.Shape) (string, error) {
output := ""
js := fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %d, {
fill: "%s",
stroke: "%s",
fill: "#000",
stroke: "#000",
strokeWidth: %d,
%s
});`, shape.Width, shape.Height, shape.Fill, shape.Stroke, shape.StrokeWidth, baseRoughProps)
});`, shape.Width, shape.Height, shape.StrokeWidth, baseRoughProps)
paths, err := computeRoughPathData(r, js)
if err != nil {
return "", err
}
pathEl := d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill, pathEl.Stroke = d2themes.ShapeTheme(shape)
pathEl.ClassName = "shape"
pathEl.Style = shape.CSSStyle()
for _, p := range paths {
output += fmt.Sprintf(
`<path class="shape" transform="translate(%d %d)" d="%s" style="%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.CSSStyle(),
)
pathEl.D = p
output += pathEl.Render()
}
box := geo.NewBox(
@ -407,24 +513,31 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
headerBox := geo.NewBox(box.TopLeft, box.Width, 2*rowHeight)
js = fmt.Sprintf(`node = rc.rectangle(0, 0, %d, %f, {
fill: "%s",
fill: "#000",
%s
});`, shape.Width, headerBox.Height, shape.Fill, baseRoughProps)
});`, shape.Width, headerBox.Height, baseRoughProps)
paths, err = computeRoughPathData(r, js)
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
pathEl.Fill = shape.Fill
pathEl.ClassName = "class_header"
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" transform="translate(%d %d)" d="%s" style="fill:%s" />`,
shape.Pos.X, shape.Pos.Y, p, shape.Fill,
)
pathEl.D = p
output += pathEl.Render()
}
output += fmt.Sprintf(
`<rect class="sketch-overlay" transform="translate(%d %d)" width="%d" height="%f" />`,
shape.Pos.X, shape.Pos.Y, shape.Width, headerBox.Height,
)
sketchOEl := d2themes.NewThemableElement("rect")
sketchOEl.SetTranslate(float64(shape.Pos.X), float64(shape.Pos.Y))
sketchOEl.Width = float64(shape.Width)
sketchOEl.Height = headerBox.Height
renderedSO, err := d2themes.NewThemableSketchOverlay(sketchOEl, pathEl.Fill).Render()
if err != nil {
return "", err
}
output += renderedSO
if shape.Label != "" {
tl := label.InsideMiddleCenter.GetPointOnBox(
@ -434,17 +547,17 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
float64(shape.LabelHeight),
)
output += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text-mono",
tl.X+float64(shape.LabelWidth)/2,
tl.Y+float64(shape.LabelHeight)*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s",
"middle",
4+shape.FontSize,
shape.Stroke,
),
svg.EscapeText(shape.Label),
textEl := d2themes.NewThemableElement("text")
textEl.X = tl.X + float64(shape.LabelWidth)/2
textEl.Y = tl.Y + float64(shape.LabelHeight)*3/4
textEl.Fill = shape.GetFontColor()
textEl.ClassName = "text-mono"
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx",
"middle",
4+shape.FontSize,
)
textEl.Content = svg.EscapeText(shape.Label)
output += textEl.Render()
}
rowBox := geo.NewBox(box.TopLeft.Copy(), box.Width, rowHeight)
@ -461,11 +574,12 @@ func Class(r *Runner, shape d2target.Shape) (string, error) {
if err != nil {
return "", err
}
pathEl = d2themes.NewThemableElement("path")
pathEl.Fill = shape.Fill
pathEl.ClassName = "class_header"
for _, p := range paths {
output += fmt.Sprintf(
`<path class="class_header" d="%s" style="fill:%s" />`,
p, shape.Fill,
)
pathEl.D = p
output += pathEl.Render()
}
for _, m := range shape.Methods {
@ -491,28 +605,27 @@ func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText str
fontSize,
)
output += strings.Join([]string{
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.PrimaryAccentColor),
prefix,
),
textEl := d2themes.NewThemableElement("text")
textEl.X = prefixTL.X
textEl.Y = prefixTL.Y + fontSize*3/4
textEl.Fill = shape.PrimaryAccentColor
textEl.ClassName = "text-mono"
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "start", fontSize)
textEl.Content = prefix
output += textEl.Render()
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X+d2target.PrefixWidth,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.Fill),
svg.EscapeText(nameText),
),
textEl.X = prefixTL.X + d2target.PrefixWidth
textEl.Fill = shape.Fill
textEl.Content = svg.EscapeText(nameText)
output += textEl.Render()
textEl.X = typeTR.X
textEl.Y = typeTR.Y + fontSize*3/4
textEl.Fill = shape.SecondaryAccentColor
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "end", fontSize)
textEl.Content = svg.EscapeText(typeText)
output += textEl.Render()
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
typeTR.X,
typeTR.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s;", "end", fontSize, shape.SecondaryAccentColor),
svg.EscapeText(typeText),
),
}, "\n")
return output
}
@ -551,12 +664,6 @@ type roughPath struct {
func (rp roughPath) StyleCSS() string {
style := ""
if rp.Style.Fill != "" {
style += fmt.Sprintf("fill:%s;", rp.Style.Fill)
}
if rp.Style.Stroke != "" {
style += fmt.Sprintf("stroke:%s;", rp.Style.Stroke)
}
if rp.Style.StrokeWidth != "" {
style += fmt.Sprintf("stroke-width:%s;", rp.Style.StrokeWidth)
}
@ -617,10 +724,11 @@ func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeW
)
case d2target.DiamondArrowhead:
arrowJS = fmt.Sprintf(
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "white", fillStyle: "solid", seed: 1 })`,
`node = rc.polygon(%s, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", seed: 1 })`,
`[[-20, 0], [-10, 5], [0, 0], [-10, -5], [-20, 0]]`,
strokeWidth,
stroke,
BG_COLOR,
)
case d2target.FilledDiamondArrowhead:
arrowJS = fmt.Sprintf(
@ -648,9 +756,10 @@ func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeW
stroke,
)
extraJS = fmt.Sprintf(
`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "white", fillStyle: "solid", fillWeight: 1, seed: 4 })`,
`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 1, seed: 4 })`,
strokeWidth,
stroke,
BG_COLOR,
)
case d2target.CfOneRequired:
arrowJS = fmt.Sprintf(
@ -669,9 +778,10 @@ func ArrowheadJS(r *Runner, arrowhead d2target.Arrowhead, stroke string, strokeW
stroke,
)
extraJS = fmt.Sprintf(
`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "white", fillStyle: "solid", fillWeight: 1, seed: 5 })`,
`node = rc.circle(-20, 0, 8, { strokeWidth: %d, stroke: "%s", fill: "%s", fillStyle: "solid", fillWeight: 1, seed: 5 })`,
strokeWidth,
stroke,
BG_COLOR,
)
}
return
@ -706,13 +816,15 @@ func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.P
roughPaths = append(roughPaths, extraPaths...)
}
pathEl := d2themes.NewThemableElement("path")
pathEl.ClassName = "connection"
pathEl.Attributes = transform
for _, rp := range roughPaths {
pathStr := fmt.Sprintf(`<path class="connection" d="%s" style="%s" %s/>`,
rp.Attrs.D,
rp.StyleCSS(),
transform,
)
arrowPaths = append(arrowPaths, pathStr)
pathEl.D = rp.Attrs.D
pathEl.Fill = rp.Style.Fill
pathEl.Stroke = rp.Style.Stroke
pathEl.Style = rp.StyleCSS()
arrowPaths = append(arrowPaths, pathEl.Render())
}
}
@ -743,13 +855,15 @@ func Arrowheads(r *Runner, connection d2target.Connection, srcAdj, dstAdj *geo.P
roughPaths = append(roughPaths, extraPaths...)
}
pathEl := d2themes.NewThemableElement("path")
pathEl.ClassName = "connection"
pathEl.Attributes = transform
for _, rp := range roughPaths {
pathStr := fmt.Sprintf(`<path class="connection" d="%s" style="%s" %s/>`,
rp.Attrs.D,
rp.StyleCSS(),
transform,
)
arrowPaths = append(arrowPaths, pathStr)
pathEl.D = rp.Attrs.D
pathEl.Fill = rp.Style.Fill
pathEl.Stroke = rp.Style.Stroke
pathEl.Style = rp.StyleCSS()
arrowPaths = append(arrowPaths, pathEl.Render())
}
}

View file

@ -49,6 +49,132 @@ func TestSketch(t *testing.T) {
script: `a -> b: hello
`,
},
{
name: "crows feet",
script: `a1 <-> b1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
a2 <-> b2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
a3 <-> b3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
c1 <-> d1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
c2 <-> d2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
c3 <-> d3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
e1 <-> f1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
e2 <-> f2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
e3 <-> f3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
g1 <-> h1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
g2 <-> h2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
g3 <-> h3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
c <-> d <-> f: {
style.stroke-width: 1
style.stroke: "orange"
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-one
}
}
`,
},
{
name: "twitter",
script: `timeline mixer: "" {
@ -345,6 +471,538 @@ users: {
last_login: datetime
style.opacity: 0.4
}
`,
},
{
name: "overlay",
script: `bright: {
style.stroke: "#000"
style.font-color: "#000"
style.fill: "#fff"
}
normal: {
style.stroke: "#000"
style.font-color: "#000"
style.fill: "#ccc"
}
dark: {
style.stroke: "#000"
style.font-color: "#fff"
style.fill: "#555"
}
darker: {
style.stroke: "#000"
style.font-color: "#fff"
style.fill: "#000"
}
`,
},
{
name: "basic dark",
themeID: 200,
script: `a -> b
`,
},
{
name: "child to child dark",
themeID: 200,
script: `winter.snow -> summer.sun
`,
},
{
name: "animated dark",
themeID: 200,
script: `winter.snow -> summer.sun -> trees -> winter.snow: { style.animated: true }
`,
},
{
name: "connection label dark",
themeID: 200,
script: `a -> b: hello
`,
},
{
name: "crows feet dark",
themeID: 200,
script: `a1 <-> b1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
a2 <-> b2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
a3 <-> b3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-many
}
target-arrowhead: {
shape: cf-many
}
}
c1 <-> d1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
c2 <-> d2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
c3 <-> d3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-many-required
}
}
e1 <-> f1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
e2 <-> f2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
e3 <-> f3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-one
}
target-arrowhead: {
shape: cf-one
}
}
g1 <-> h1: {
style.stroke-width: 1
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
g2 <-> h2: {
style.stroke-width: 3
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
g3 <-> h3: {
style.stroke-width: 6
source-arrowhead: {
shape: cf-one-required
}
target-arrowhead: {
shape: cf-one-required
}
}
c <-> d <-> f: {
style.stroke-width: 1
style.stroke: "orange"
source-arrowhead: {
shape: cf-many-required
}
target-arrowhead: {
shape: cf-one
}
}
`,
},
{
name: "twitter dark",
themeID: 200,
script: `timeline mixer: "" {
explanation: |md
## **Timeline mixer**
- Inject ads, who-to-follow, onboarding
- Conversation module
- Cursoring,pagination
- Tweat deduplication
- Served data logging
|
}
People discovery: "People discovery \nservice"
admixer: Ad mixer {
style.fill: "#c1a2f3"
}
onboarding service: "Onboarding \nservice"
timeline mixer -> People discovery
timeline mixer -> onboarding service
timeline mixer -> admixer
container0: "" {
graphql
comment
tlsapi
}
container0.graphql: GraphQL\nFederated Strato Column {
shape: image
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/GraphQL_Logo.svg/1200px-GraphQL_Logo.svg.png
}
container0.comment: |md
## Tweet/user content hydration, visibility filtering
|
container0.tlsapi: TLS-API (being deprecated)
container0.graphql -> timeline mixer
timeline mixer <- container0.tlsapi
twitter fe: "Twitter Frontend " {
icon: https://icons.terrastruct.com/social/013-twitter-1.svg
shape: image
}
twitter fe -> container0.graphql: iPhone web
twitter fe -> container0.tlsapi: HTTP Android
web: Web {
icon: https://icons.terrastruct.com/azure/Web%20Service%20Color/App%20Service%20Domains.svg
shape: image
}
Iphone: {
icon: 'https://ss7.vzw.com/is/image/VerizonWireless/apple-iphone-12-64gb-purple-53017-mjn13ll-a?$device-lg$'
shape: image
}
Android: {
icon: https://cdn4.iconfinder.com/data/icons/smart-phones-technologies/512/android-phone.png
shape: image
}
web -> twitter fe
timeline scorer: "Timeline\nScorer" {
style.fill "#ffdef1"
}
home ranker: Home Ranker
timeline service: Timeline Service
timeline mixer -> timeline scorer: Thrift RPC
timeline mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
timeline mixer -> timeline service
home mixer: Home mixer {
# style.fill "#c1a2f3"
}
container0.graphql -> home mixer: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline scorer
home mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline service
manhattan 2: Manhattan
gizmoduck: Gizmoduck
socialgraph: Social graph
tweetypie: Tweety Pie
home mixer -> manhattan 2
home mixer -> gizmoduck
home mixer -> socialgraph
home mixer -> tweetypie
Iphone -> twitter fe
Android -> twitter fe
prediction service2: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
home scorer: Home Scorer {
style.fill "#ffdef1"
}
manhattan: Manhattan
memcache: Memcache {
icon: https://d1q6f0aelx0por.cloudfront.net/product-logos/de041504-0ddb-43f6-b89e-fe04403cca8d-memcached.png
}
fetch: Fetch {
style.multiple: true
shape: step
}
feature: Feature {
style.multiple: true
shape: step
}
scoring: Scoring {
style.multiple: true
shape: step
}
fetch -> feature
feature -> scoring
prediction service: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
scoring -> prediction service
fetch -> container2.crmixer
home scorer -> manhattan: ""
home scorer -> memcache: ""
home scorer -> prediction service2
home ranker -> home scorer
home ranker -> container2.crmixer: Candidate Fetch
container2: "" {
style.stroke: "#000E3D"
style.fill: "#ffffff"
crmixer: CrMixer {
style.fill: "#F7F8FE"
}
earlybird: EarlyBird
utag: Utag
space: Space
communities: Communities
}
etc: ...etc
home scorer -> etc: Feature Hydration
feature -> manhattan
feature -> memcache
feature -> etc: Candidate sources
`,
},
{
name: "all_shapes dark",
themeID: 200,
script: `
rectangle: {shape: "rectangle"}
square: {shape: "square"}
page: {shape: "page"}
parallelogram: {shape: "parallelogram"}
document: {shape: "document"}
cylinder: {shape: "cylinder"}
queue: {shape: "queue"}
package: {shape: "package"}
step: {shape: "step"}
callout: {shape: "callout"}
stored_data: {shape: "stored_data"}
person: {shape: "person"}
diamond: {shape: "diamond"}
oval: {shape: "oval"}
circle: {shape: "circle"}
hexagon: {shape: "hexagon"}
cloud: {shape: "cloud"}
rectangle -> square -> page
parallelogram -> document -> cylinder
queue -> package -> step
callout -> stored_data -> person
diamond -> oval -> circle
hexagon -> cloud
`,
},
{
name: "sql_tables dark",
themeID: 200,
script: `users: {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
products: {
shape: sql_table
id: int
price: decimal
sku: string
name: string
}
orders: {
shape: sql_table
id: int
user_id: int
product_id: int
}
shipments: {
shape: sql_table
id: int
order_id: int
tracking_number: string {constraint: primary_key}
status: string
}
users.id <-> orders.user_id
products.id <-> orders.product_id
shipments.order_id <-> orders.id`,
},
{
name: "class dark",
themeID: 200,
script: `manager: BatchManager {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
`,
},
{
name: "arrowheads dark",
themeID: 200,
script: `
a: ""
b: ""
a.1 -- b.1: none
a.2 <-> b.2: arrow {
source-arrowhead.shape: arrow
target-arrowhead.shape: arrow
}
a.3 <-> b.3: triangle {
source-arrowhead.shape: triangle
target-arrowhead.shape: triangle
}
a.4 <-> b.4: diamond {
source-arrowhead.shape: diamond
target-arrowhead.shape: diamond
}
a.5 <-> b.5: diamond filled {
source-arrowhead: {
shape: diamond
style.filled: true
}
target-arrowhead: {
shape: diamond
style.filled: true
}
}
a.6 <-> b.6: cf-many {
source-arrowhead.shape: cf-many
target-arrowhead.shape: cf-many
}
a.7 <-> b.7: cf-many-required {
source-arrowhead.shape: cf-many-required
target-arrowhead.shape: cf-many-required
}
a.8 <-> b.8: cf-one {
source-arrowhead.shape: cf-one
target-arrowhead.shape: cf-one
}
a.9 <-> b.9: cf-one-required {
source-arrowhead.shape: cf-one-required
target-arrowhead.shape: cf-one-required
}
`,
},
{
name: "opacity dark",
themeID: 200,
script: `x.style.opacity: 0.4
y: |md
linux: because a PC is a terrible thing to waste
| {
style.opacity: 0.4
}
x -> a: {
label: You don't have to know how the computer works,\njust how to work the computer.
style.opacity: 0.4
}
users: {
shape: sql_table
last_login: datetime
style.opacity: 0.4
}
`,
},
{
name: "root-fill",
script: `style.fill: honeydew
style.stroke: LightSteelBlue
style.double-border: true
title: Flow-I (Warehousing, Installation) {
near: top-center
shape: text
style: {
font-size: 24
bold: false
underline: false
}
}
OEM Factory
OEM Factory -> OEM Warehouse
OEM Factory -> Distributor Warehouse
OEM Factory -> company Warehouse
company Warehouse.Master -> company Warehouse.Regional-1
company Warehouse.Master -> company Warehouse.Regional-2
company Warehouse.Master -> company Warehouse.Regional-N
company Warehouse.Regional-1 -> company Warehouse.Regional-2
company Warehouse.Regional-2 -> company Warehouse.Regional-N
company Warehouse.Regional-N -> company Warehouse.Regional-1
company Warehouse.explanation: |md
### company Warehouse
- Asset Tagging
- Inventory
- Staging
- Dispatch to Site
|
`,
},
{
name: "double-border",
script: `a: {
style.double-border: true
b
}
c: {
shape: oval
style.double-border: true
d
}
normal: {
nested normal
}
something
`,
},
}
@ -352,9 +1010,10 @@ users: {
}
type testCase struct {
name string
script string
skip bool
name string
themeID int64
script string
skip bool
}
func runa(t *testing.T, tcs []testCase) {
@ -383,7 +1042,6 @@ func run(t *testing.T, tc testCase) {
diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{
Ruler: ruler,
ThemeID: 0,
Layout: d2dagrelayout.DefaultLayout,
FontFamily: go2.Pointer(d2fonts.HandDrawn),
})
@ -395,8 +1053,9 @@ func run(t *testing.T, tc testCase) {
pathGotSVG := filepath.Join(dataPath, "sketch.got.svg")
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
Sketch: true,
Pad: d2svg.DEFAULT_PADDING,
Sketch: true,
ThemeID: tc.themeID,
})
assert.Success(t, err)
err = os.MkdirAll(dataPath, 0755)
@ -409,6 +1068,6 @@ func run(t *testing.T, tc testCase) {
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
err = diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
assert.Success(t, err)
// We want the visual diffs to compare, but there's floating point precision differences between CI and user machines, so don't compare raw strings
diff.Testdata(filepath.Join(dataPath, "sketch"), ".svg", svgBytes)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 298 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 288 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 284 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 274 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 334 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 325 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 218 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 279 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 270 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 226 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 217 KiB

View file

@ -1,136 +0,0 @@
{
"name": "",
"fontFamily": "HandDrawn",
"shapes": [
{
"id": "a",
"type": "",
"pos": {
"x": 1,
"y": 0
},
"width": 114,
"height": 126,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#F7F8FE",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "a",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 14,
"labelHeight": 26,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
},
{
"id": "b",
"type": "",
"pos": {
"x": 0,
"y": 226
},
"width": 115,
"height": 126,
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"borderRadius": 0,
"fill": "#F7F8FE",
"stroke": "#0D32B2",
"shadow": false,
"3d": false,
"multiple": false,
"tooltip": "",
"link": "",
"icon": null,
"iconPosition": "",
"blend": false,
"fields": null,
"methods": null,
"columns": null,
"label": "b",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#0A0F25",
"italic": false,
"bold": true,
"underline": false,
"labelWidth": 15,
"labelHeight": 26,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"zIndex": 0,
"level": 1
}
],
"connections": [
{
"id": "(a -> b)[0]",
"src": "a",
"srcArrow": "none",
"srcLabel": "",
"dst": "b",
"dstArrow": "triangle",
"dstLabel": "",
"opacity": 1,
"strokeDash": 0,
"strokeWidth": 2,
"stroke": "#0D32B2",
"label": "hello",
"fontSize": 16,
"fontFamily": "DEFAULT",
"language": "",
"color": "#676C7E",
"italic": true,
"bold": false,
"underline": false,
"labelWidth": 31,
"labelHeight": 23,
"labelPosition": "INSIDE_MIDDLE_CENTER",
"labelPercentage": 0,
"route": [
{
"x": 57.5,
"y": 126
},
{
"x": 57.5,
"y": 166
},
{
"x": 57.5,
"y": 186
},
{
"x": 57.5,
"y": 226
}
],
"isCurve": true,
"animated": false,
"tooltip": "",
"icon": null,
"zIndex": 0
}
]
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 246 KiB

After

Width:  |  Height:  |  Size: 276 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 267 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 332 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 323 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 289 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 309 KiB

After

Width:  |  Height:  |  Size: 340 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 330 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 228 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 306 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 114 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 387 KiB

After

Width:  |  Height:  |  Size: 418 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 418 KiB

View file

@ -14,6 +14,8 @@ import (
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/color"
"oss.terrastruct.com/d2/lib/textmeasure"
"oss.terrastruct.com/util-go/go2"
)
@ -48,8 +50,8 @@ const (
)
var viewboxRegex = regexp.MustCompile(`viewBox=\"([0-9\- ]+)\"`)
var widthRegex = regexp.MustCompile(`width=\"([0-9]+)\"`)
var heightRegex = regexp.MustCompile(`height=\"([0-9]+)\"`)
var widthRegex = regexp.MustCompile(`width=\"([.0-9]+)\"`)
var heightRegex = regexp.MustCompile(`height=\"([.0-9]+)\"`)
func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []byte {
svg := string(in)
@ -60,7 +62,9 @@ func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []by
return in
}
viewboxMatch := viewboxRegex.FindStringSubmatch(svg)
// match 1st two viewboxes, 1st is outer fit-to-screen viewbox="0 0 innerWidth innerHeight"
viewboxMatches := viewboxRegex.FindAllStringSubmatch(svg, 2)
viewboxMatch := viewboxMatches[1]
viewboxRaw := viewboxMatch[1]
viewboxSlice := strings.Split(viewboxRaw, " ")
viewboxPadLeft, _ := strconv.Atoi(viewboxSlice[0])
@ -68,9 +72,13 @@ func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []by
viewboxHeight, _ := strconv.Atoi(viewboxSlice[3])
tl, br := diagram.BoundingBox()
seperator := fmt.Sprintf(`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="#0A0F25" />`,
tl.X-PAD_SIDES, br.Y+PAD_TOP, go2.IntMax(w, br.X)+PAD_SIDES, br.Y+PAD_TOP)
appendix = seperator + appendix
separatorEl := d2themes.NewThemableElement("line")
separatorEl.X1 = float64(tl.X - PAD_SIDES)
separatorEl.Y1 = float64(br.Y + PAD_TOP)
separatorEl.X2 = float64(go2.IntMax(w, br.X) + PAD_SIDES)
separatorEl.Y2 = float64(br.Y + PAD_TOP)
separatorEl.Stroke = color.B2 // same as --color-border-muted in markdown
appendix = separatorEl.Render() + appendix
w -= viewboxPadLeft
w += PAD_SIDES * 2
@ -80,16 +88,20 @@ func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []by
viewboxHeight += h + PAD_TOP
newOuterViewbox := fmt.Sprintf(`viewBox="0 0 %d %d"`, viewboxWidth, viewboxHeight)
newViewbox := fmt.Sprintf(`viewBox="%s %s %s %s"`, viewboxSlice[0], viewboxSlice[1], strconv.Itoa(viewboxWidth), strconv.Itoa(viewboxHeight))
widthMatch := widthRegex.FindStringSubmatch(svg)
heightMatch := heightRegex.FindStringSubmatch(svg)
widthMatches := widthRegex.FindAllStringSubmatch(svg, 2)
heightMatches := heightRegex.FindAllStringSubmatch(svg, 2)
newWidth := fmt.Sprintf(`width="%s"`, strconv.Itoa(viewboxWidth))
newHeight := fmt.Sprintf(`height="%s"`, strconv.Itoa(viewboxHeight))
svg = strings.Replace(svg, viewboxMatches[0][0], newOuterViewbox, 1)
svg = strings.Replace(svg, viewboxMatch[0], newViewbox, 1)
svg = strings.Replace(svg, widthMatch[0], newWidth, 1)
svg = strings.Replace(svg, heightMatch[0], newHeight, 1)
for i := 0; i < 2; i++ {
svg = strings.Replace(svg, widthMatches[i][0], newWidth, 1)
svg = strings.Replace(svg, heightMatches[i][0], newHeight, 1)
}
if !strings.Contains(svg, `font-family: "font-regular"`) {
appendix += fmt.Sprintf(`<style type="text/css"><![CDATA[
@ -114,7 +126,7 @@ func Append(diagram *d2target.Diagram, ruler *textmeasure.Ruler, in []byte) []by
]]></style>`, d2fonts.FontEncodings[d2fonts.SourceSansPro.Font(0, d2fonts.FONT_STYLE_BOLD)])
}
closingIndex := strings.LastIndex(svg, "</svg>")
closingIndex := strings.LastIndex(svg, "</svg></svg>")
svg = svg[:closingIndex] + appendix + svg[closingIndex:]
i := 1
@ -158,7 +170,7 @@ func generateAppendix(diagram *d2target.Diagram, ruler *textmeasure.Ruler, svg s
}
totalHeight += SPACER
return fmt.Sprintf(`<g x="%d" y="%d" width="%d" height="100%%">%s</g>
return fmt.Sprintf(`<g class="appendix" x="%d" y="%d" width="%d" height="100%%">%s</g>
`, tl.X, br.Y, (br.X - tl.X), strings.Join(lines, "\n")), maxWidth, totalHeight
}

View file

@ -82,6 +82,22 @@ payment processor behind the scenes: {
script: `x: { link: https://d2lang.com }
y: { link: https://terrastruct.com; tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
{
name: "links dark",
themeID: 200,
script: `x: { link: https://d2lang.com }
y: { link: https://fosny.eu; tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
`,
},
{
name: "tooltip_fill",
script: `x: { tooltip: Total abstinence is easier than perfect moderation }
y: { tooltip: Gee, I feel kind of LIGHT in the head now,\nknowing I can't make my satellite dish PAYMENTS! }
x -> y
style.fill: PaleVioletRed
`,
},
}
@ -89,9 +105,10 @@ x -> y
}
type testCase struct {
name string
script string
skip bool
name string
themeID int64
script string
skip bool
}
func runa(t *testing.T, tcs []testCase) {
@ -119,9 +136,8 @@ func run(t *testing.T, tc testCase) {
}
diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{
Ruler: ruler,
ThemeID: 0,
Layout: d2dagrelayout.DefaultLayout,
Ruler: ruler,
Layout: d2dagrelayout.DefaultLayout,
})
if !tassert.Nil(t, err) {
return
@ -131,7 +147,8 @@ func run(t *testing.T, tc testCase) {
pathGotSVG := filepath.Join(dataPath, "sketch.got.svg")
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
Pad: d2svg.DEFAULT_PADDING,
ThemeID: tc.themeID,
})
assert.Success(t, err)
svgBytes = appendix.Append(diagram, ruler, svgBytes)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 803 KiB

After

Width:  |  Height:  |  Size: 806 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 651 KiB

After

Width:  |  Height:  |  Size: 654 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 654 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 654 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 654 KiB

View file

@ -3,17 +3,21 @@ package d2svg
import (
"fmt"
"io"
"strings"
"oss.terrastruct.com/d2/d2target"
"oss.terrastruct.com/d2/d2themes"
"oss.terrastruct.com/d2/lib/geo"
"oss.terrastruct.com/d2/lib/label"
"oss.terrastruct.com/d2/lib/svg"
)
func classHeader(shape d2target.Shape, box *geo.Box, text string, textWidth, textHeight, fontSize float64) string {
str := fmt.Sprintf(`<rect class="class_header" x="%f" y="%f" width="%f" height="%f" fill="%s" />`,
box.TopLeft.X, box.TopLeft.Y, box.Width, box.Height, shape.Fill)
rectEl := d2themes.NewThemableElement("rect")
rectEl.X, rectEl.Y = box.TopLeft.X, box.TopLeft.Y
rectEl.Width, rectEl.Height = box.Width, box.Height
rectEl.Fill = shape.Fill
rectEl.ClassName = "class_header"
str := rectEl.Render()
if text != "" {
tl := label.InsideMiddleCenter.GetPointOnBox(
@ -23,17 +27,16 @@ func classHeader(shape d2target.Shape, box *geo.Box, text string, textWidth, tex
textHeight,
)
str += fmt.Sprintf(`<text class="%s" x="%f" y="%f" style="%s">%s</text>`,
"text-mono",
tl.X+textWidth/2,
tl.Y+textHeight*3/4,
fmt.Sprintf(`text-anchor:%s;font-size:%vpx;fill:%s`,
"middle",
4+fontSize,
shape.Stroke,
),
svg.EscapeText(text),
textEl := d2themes.NewThemableElement("text")
textEl.X = tl.X + textWidth/2
textEl.Y = tl.Y + textHeight*3/4
textEl.Fill = shape.GetFontColor()
textEl.ClassName = "text-mono"
textEl.Style = fmt.Sprintf(`text-anchor:%s;font-size:%vpx;`,
"middle", 4+fontSize,
)
textEl.Content = svg.EscapeText(text)
str += textEl.Render()
}
return str
}
@ -54,33 +57,39 @@ func classRow(shape d2target.Shape, box *geo.Box, prefix, nameText, typeText str
fontSize,
)
return strings.Join([]string{
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.PrimaryAccentColor),
prefix,
),
textEl := d2themes.NewThemableElement("text")
textEl.X = prefixTL.X
textEl.Y = prefixTL.Y + fontSize*3/4
textEl.Fill = shape.PrimaryAccentColor
textEl.ClassName = "text-mono"
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "start", fontSize)
textEl.Content = prefix
out := textEl.Render()
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
prefixTL.X+d2target.PrefixWidth,
prefixTL.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "start", fontSize, shape.Fill),
svg.EscapeText(nameText),
),
textEl.X = prefixTL.X + d2target.PrefixWidth
textEl.Fill = shape.Fill
textEl.Content = svg.EscapeText(nameText)
out += textEl.Render()
fmt.Sprintf(`<text class="text-mono" x="%f" y="%f" style="%s">%s</text>`,
typeTR.X,
typeTR.Y+fontSize*3/4,
fmt.Sprintf("text-anchor:%s;font-size:%vpx;fill:%s", "end", fontSize, shape.SecondaryAccentColor),
svg.EscapeText(typeText),
),
}, "\n")
textEl.X = typeTR.X
textEl.Y = typeTR.Y + fontSize*3/4
textEl.Fill = shape.SecondaryAccentColor
textEl.Style = fmt.Sprintf("text-anchor:%s;font-size:%vpx", "end", fontSize)
textEl.Content = svg.EscapeText(typeText)
out += textEl.Render()
return out
}
func drawClass(writer io.Writer, targetShape d2target.Shape) {
fmt.Fprintf(writer, `<rect class="shape" x="%d" y="%d" width="%d" height="%d" style="%s"/>`,
targetShape.Pos.X, targetShape.Pos.Y, targetShape.Width, targetShape.Height, targetShape.CSSStyle())
el := d2themes.NewThemableElement("rect")
el.X = float64(targetShape.Pos.X)
el.Y = float64(targetShape.Pos.Y)
el.Width = float64(targetShape.Width)
el.Height = float64(targetShape.Height)
el.Fill, el.Stroke = d2themes.ShapeTheme(targetShape)
el.Style = targetShape.CSSStyle()
fmt.Fprint(writer, el.Render())
box := geo.NewBox(
geo.NewPoint(float64(targetShape.Pos.X), float64(targetShape.Pos.Y)),
@ -103,10 +112,12 @@ func drawClass(writer io.Writer, targetShape d2target.Shape) {
rowBox.TopLeft.Y += rowHeight
}
fmt.Fprintf(writer, `<line x1="%f" y1="%f" x2="%f" y2="%f" style="%s" />`,
rowBox.TopLeft.X, rowBox.TopLeft.Y,
rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y,
fmt.Sprintf("stroke-width:1;stroke:%v", targetShape.Fill))
lineEl := d2themes.NewThemableElement("line")
lineEl.X1, lineEl.Y1 = rowBox.TopLeft.X, rowBox.TopLeft.Y
lineEl.X2, lineEl.Y2 = rowBox.TopLeft.X+rowBox.Width, rowBox.TopLeft.Y
lineEl.Stroke = targetShape.Fill
lineEl.Style = "stroke-width:1"
fmt.Fprint(writer, lineEl.Render())
for _, m := range targetShape.Methods {
fmt.Fprint(writer,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,441 @@
package dark_theme_test
import (
"context"
"encoding/xml"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"cdr.dev/slog"
tassert "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/assert"
"oss.terrastruct.com/util-go/diff"
"oss.terrastruct.com/util-go/go2"
"oss.terrastruct.com/d2/d2layouts/d2dagrelayout"
"oss.terrastruct.com/d2/d2lib"
"oss.terrastruct.com/d2/d2renderers/d2fonts"
"oss.terrastruct.com/d2/d2renderers/d2svg"
"oss.terrastruct.com/d2/lib/log"
"oss.terrastruct.com/d2/lib/textmeasure"
)
func TestDarkTheme(t *testing.T) {
t.Parallel()
tcs := []testCase{
{
name: "basic",
script: `a -> b
`,
},
{
name: "child to child",
script: `winter.snow -> summer.sun
`,
},
{
name: "animated",
script: `winter.snow -> summer.sun -> trees -> winter.snow: { style.animated: true }
`,
},
{
name: "connection label",
script: `a -> b: hello
`,
},
{
name: "twitter",
script: `timeline mixer: "" {
explanation: |md
## **Timeline mixer**
- Inject ads, who-to-follow, onboarding
- Conversation module
- Cursoring,pagination
- Tweat deduplication
- Served data logging
|
}
People discovery: "People discovery \nservice"
admixer: Ad mixer {
style.fill: "#cba6f7"
style.font-color: "#000000"
}
onboarding service: "Onboarding \nservice"
timeline mixer -> People discovery
timeline mixer -> onboarding service
timeline mixer -> admixer
container0: "" {
graphql
comment
tlsapi
}
container0.graphql: GraphQL\nFederated Strato Column {
shape: image
icon: https://upload.wikimedia.org/wikipedia/commons/thumb/1/17/GraphQL_Logo.svg/1200px-GraphQL_Logo.svg.png
}
container0.comment: |md
## Tweet/user content hydration, visibility filtering
|
container0.tlsapi: TLS-API (being deprecated)
container0.graphql -> timeline mixer
timeline mixer <- container0.tlsapi
twitter fe: "Twitter Frontend " {
icon: https://icons.terrastruct.com/social/013-twitter-1.svg
shape: image
}
twitter fe -> container0.graphql: iPhone web
twitter fe -> container0.tlsapi: HTTP Android
web: Web {
icon: https://icons.terrastruct.com/azure/Web%20Service%20Color/App%20Service%20Domains.svg
shape: image
}
Iphone: {
icon: 'https://ss7.vzw.com/is/image/VerizonWireless/apple-iphone-12-64gb-purple-53017-mjn13ll-a?$device-lg$'
shape: image
}
Android: {
icon: https://cdn4.iconfinder.com/data/icons/smart-phones-technologies/512/android-phone.png
shape: image
}
web -> twitter fe
timeline scorer: "Timeline\nScorer" {
style.fill: "#fab387"
style.font-color: "#000000"
}
home ranker: Home Ranker
timeline service: Timeline Service
timeline mixer -> timeline scorer: Thrift RPC
timeline mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
timeline mixer -> timeline service
home mixer: Home mixer {
# style.fill: "#c1a2f3"
}
container0.graphql -> home mixer: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline scorer
home mixer -> home ranker: {
style.stroke-dash: 4
style.stroke: "#000E3D"
}
home mixer -> timeline service
manhattan 2: Manhattan
gizmoduck: Gizmoduck
socialgraph: Social graph
tweetypie: Tweety Pie
home mixer -> manhattan 2
home mixer -> gizmoduck
home mixer -> socialgraph
home mixer -> tweetypie
Iphone -> twitter fe
Android -> twitter fe
prediction service2: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
home scorer: Home Scorer {
style.fill: "#eba0ac"
style.font-color: "#000000"
}
manhattan: Manhattan
memcache: Memcache {
icon: https://d1q6f0aelx0por.cloudfront.net/product-logos/de041504-0ddb-43f6-b89e-fe04403cca8d-memcached.png
}
fetch: Fetch {
style.multiple: true
shape: step
}
feature: Feature {
style.multiple: true
shape: step
}
scoring: Scoring {
style.multiple: true
shape: step
}
fetch -> feature
feature -> scoring
prediction service: Prediction Service {
shape: image
icon: https://cdn-icons-png.flaticon.com/512/6461/6461819.png
}
scoring -> prediction service
fetch -> container2.crmixer
home scorer -> manhattan: ""
home scorer -> memcache: ""
home scorer -> prediction service2
home ranker -> home scorer
home ranker -> container2.crmixer: Candidate Fetch
container2: "" {
style.stroke: "#b4befe"
style.fill: "#000000"
crmixer: CrMixer {
style.fill: "#11111b"
style.font-color: "#cdd6f4"
}
earlybird: EarlyBird
utag: Utag
space: Space
communities: Communities
}
etc: ...etc
home scorer -> etc: Feature Hydration
feature -> manhattan
feature -> memcache
feature -> etc: Candidate sources
`,
},
{
name: "all_shapes",
script: `
rectangle: {shape: "rectangle"}
square: {shape: "square"}
page: {shape: "page"}
parallelogram: {shape: "parallelogram"}
document: {shape: "document"}
cylinder: {shape: "cylinder"}
queue: {shape: "queue"}
package: {shape: "package"}
step: {shape: "step"}
callout: {shape: "callout"}
stored_data: {shape: "stored_data"}
person: {shape: "person"}
diamond: {shape: "diamond"}
oval: {shape: "oval"}
circle: {shape: "circle"}
hexagon: {shape: "hexagon"}
cloud: {shape: "cloud"}
rectangle -> square -> page
parallelogram -> document -> cylinder
queue -> package -> step
callout -> stored_data -> person
diamond -> oval -> circle
hexagon -> cloud
`,
},
{
name: "sql_tables",
script: `users: {
shape: sql_table
id: int
name: string
email: string
password: string
last_login: datetime
}
products: {
shape: sql_table
id: int
price: decimal
sku: string
name: string
}
orders: {
shape: sql_table
id: int
user_id: int
product_id: int
}
shipments: {
shape: sql_table
id: int
order_id: int
tracking_number: string {constraint: primary_key}
status: string
}
users.id <-> orders.user_id
products.id <-> orders.product_id
shipments.order_id <-> orders.id`,
},
{
name: "class",
script: `manager: BatchManager {
shape: class
-num: int
-timeout: int
-pid
+getStatus(): Enum
+getJobs(): "Job[]"
+setTimeout(seconds int)
}
`,
},
{
name: "arrowheads",
script: `
a: ""
b: ""
a.1 -- b.1: none
a.2 <-> b.2: arrow {
source-arrowhead.shape: arrow
target-arrowhead.shape: arrow
}
a.3 <-> b.3: triangle {
source-arrowhead.shape: triangle
target-arrowhead.shape: triangle
}
a.4 <-> b.4: diamond {
source-arrowhead.shape: diamond
target-arrowhead.shape: diamond
}
a.5 <-> b.5: diamond filled {
source-arrowhead: {
shape: diamond
style.filled: true
}
target-arrowhead: {
shape: diamond
style.filled: true
}
}
a.6 <-> b.6: cf-many {
source-arrowhead.shape: cf-many
target-arrowhead.shape: cf-many
}
a.7 <-> b.7: cf-many-required {
source-arrowhead.shape: cf-many-required
target-arrowhead.shape: cf-many-required
}
a.8 <-> b.8: cf-one {
source-arrowhead.shape: cf-one
target-arrowhead.shape: cf-one
}
a.9 <-> b.9: cf-one-required {
source-arrowhead.shape: cf-one-required
target-arrowhead.shape: cf-one-required
}
`,
},
{
name: "opacity",
script: `x.style.opacity: 0.4
y: |md
linux: because a PC is a terrible thing to waste
| {
style.opacity: 0.4
}
x -> a: {
label: You don't have to know how the computer works,\njust how to work the computer.
style.opacity: 0.4
}
users: {
shape: sql_table
last_login: datetime
style.opacity: 0.4
}
`,
},
{
name: "overlay",
script: `bright: {
style.stroke: "#000"
style.font-color: "#000"
style.fill: "#fff"
}
normal: {
style.stroke: "#000"
style.font-color: "#000"
style.fill: "#ccc"
}
dark: {
style.stroke: "#000"
style.font-color: "#fff"
style.fill: "#555"
}
darker: {
style.stroke: "#000"
style.font-color: "#fff"
style.fill: "#000"
}
`,
},
}
runa(t, tcs)
}
type testCase struct {
name string
script string
skip bool
}
func runa(t *testing.T, tcs []testCase) {
for _, tc := range tcs {
tc := tc
t.Run(tc.name, func(t *testing.T) {
if tc.skip {
t.Skip()
}
t.Parallel()
run(t, tc)
})
}
}
func run(t *testing.T, tc testCase) {
ctx := context.Background()
ctx = log.WithTB(ctx, t, nil)
ctx = log.Leveled(ctx, slog.LevelDebug)
ruler, err := textmeasure.NewRuler()
if !tassert.Nil(t, err) {
return
}
diagram, _, err := d2lib.Compile(ctx, tc.script, &d2lib.CompileOptions{
Ruler: ruler,
Layout: d2dagrelayout.DefaultLayout,
FontFamily: go2.Pointer(d2fonts.HandDrawn),
})
if !tassert.Nil(t, err) {
return
}
dataPath := filepath.Join("testdata", strings.TrimPrefix(t.Name(), "TestDarkTheme/"))
pathGotSVG := filepath.Join(dataPath, "dark_theme.got.svg")
svgBytes, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: d2svg.DEFAULT_PADDING,
ThemeID: 200,
})
assert.Success(t, err)
err = os.MkdirAll(dataPath, 0755)
assert.Success(t, err)
err = ioutil.WriteFile(pathGotSVG, svgBytes, 0600)
assert.Success(t, err)
defer os.Remove(pathGotSVG)
var xmlParsed interface{}
err = xml.Unmarshal(svgBytes, &xmlParsed)
assert.Success(t, err)
err = diff.Testdata(filepath.Join(dataPath, "dark_theme"), ".svg", svgBytes)
assert.Success(t, err)
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 196 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 239 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 252 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 188 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 237 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 188 KiB

Some files were not shown because too many files have changed in this diff Show more