From 44056c653046f2766ba9447448d7afe254859620 Mon Sep 17 00:00:00 2001 From: ihopenre-eng <247072151+ihopenre-eng@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:17:28 +0700 Subject: [PATCH] fix(textmeasure): account for Unicode glyph advances Signed-off-by: ihopenre-eng <247072151+ihopenre-eng@users.noreply.github.com> --- ci/release/changelogs/next.md | 1 + .../cloud-run-egress/cloud-run-egress.d2 | 127 +++ .../cloud-run-egress/cloud-run-egress.svg | 869 ++++++++++++++++++ e2etests/stable_test.go | 10 + .../dagre/board.exp.json | 232 +++++ .../dagre/sketch.exp.svg | 842 +++++++++++++++++ lib/textmeasure/textmeasure.go | 29 + lib/textmeasure/textmeasure_test.go | 1 + 8 files changed, 2111 insertions(+) create mode 100644 docs/examples/cloud-run-egress/cloud-run-egress.d2 create mode 100644 docs/examples/cloud-run-egress/cloud-run-egress.svg create mode 100644 e2etests/testdata/stable/unicode_markdown_title/dagre/board.exp.json create mode 100644 e2etests/testdata/stable/unicode_markdown_title/dagre/sketch.exp.svg diff --git a/ci/release/changelogs/next.md b/ci/release/changelogs/next.md index f4616390ed..1a1719ebde 100644 --- a/ci/release/changelogs/next.md +++ b/ci/release/changelogs/next.md @@ -19,6 +19,7 @@ - exports: pptx follows standards more closely, addressing warnings from some Powerpoint software [#2645](https://github.com/terrastruct/d2/pull/2645) - d2sequence: fix edge case of invalid sequence diagrams [#2660](https://github.com/terrastruct/d2/pull/2660) - d2svg: Text may overflow legend bounds when monospace font is used [#2674](https://github.com/terrastruct/d2/pull/2674) +- d2svg: markdown labels with Unicode punctuation no longer wrap unexpectedly [#2647](https://github.com/terrastruct/d2/issues/2647) --- diff --git a/docs/examples/cloud-run-egress/cloud-run-egress.d2 b/docs/examples/cloud-run-egress/cloud-run-egress.d2 new file mode 100644 index 0000000000..c9be103be5 --- /dev/null +++ b/docs/examples/cloud-run-egress/cloud-run-egress.d2 @@ -0,0 +1,127 @@ +title: { + shape: text + label: |md + # Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher + | + near: top-center +} + +Cloud_Run_Services: { + label: "Cloud Run Services" + style: { + fill: "#fbfbfd" + } + + Backend: { + label: "Backend\n(tag: egress-web)\nDirect VPC egress: ALL_TRAFFIC" + style: {fill: "#e8f0fe"} + } + + Exporter: { + label: "Exporter\n(tag: egress-sentry)\nDirect VPC egress: ALL_TRAFFIC" + style: {fill: "#e7f8ef"} + } + + Autolayout: { + label: "Autolayout\n(tag: egress-none)\nDirect VPC egress: ALL_TRAFFIC" + style: {fill: "#fdeee8"} + } + + Fetcher: { + label: "Fetcher\n(tag: egress-fetcher)\nDirect VPC egress: ALL_TRAFFIC" + style: {fill: "#fff7cc"} + } +} + +VPC: { + label: "Your VPC (firewall / routes apply here)" + style: { + fill: "#f4f6fb" + } + + FW: { + shape: page + label: |md + ## Egress Firewall Policies + + - Allow DNS (UDP/TCP 53) → 169.254.169.254 + - Deny Metadata (TCP 80/443) → 169.254.169.254 + - Allow → SWP VIP:80,443 (for tags: egress-web, egress-sentry, egress-fetcher) + - Allow private RFC1918/required backends (optional) + - Deny-All catch-all (per tag; e.g., egress-none) + | + } + + SWP: { + label: "Secure Web Proxy (SWP)\nExplicit HTTP/HTTPS proxy\n(domain/URL policy, logs)" + shape: cylinder + } + + NAT: { + label: "Cloud NAT\nEgress IPs to Internet" + shape: cylinder + } +} + +Internet: { + shape: cloud + label: "Internet" +} + +Sentry: { + label: "sentry.io" +} + +ImageHosts: { + label: "Any image hosts" +} + +# Ingress constraints to Autolayout (IAM-based) +Cloud_Run_Services.Backend -> Cloud_Run_Services.Autolayout: { + label: "ingress (Run Invoker SA)" + style: {stroke-dash: 3} +} + +Cloud_Run_Services.Exporter -> Cloud_Run_Services.Autolayout: { + label: "ingress (Run Invoker SA)" + style: {stroke-dash: 3} +} + +# Backend calls Fetcher for arbitrary image fetching +Cloud_Run_Services.Backend -> Cloud_Run_Services.Fetcher: { + label: "HTTPS (OIDC) call for image fetching" + style: {stroke-dash: 3} +} + +# Service egress paths into VPC and through SWP +Cloud_Run_Services.Backend -> VPC.SWP: "via VPC FW → SWP" +Cloud_Run_Services.Exporter -> VPC.SWP: "via VPC FW → SWP" +Cloud_Run_Services.Fetcher -> VPC.SWP: "via VPC FW → SWP" + +# Autolayout egress explicitly denied +Cloud_Run_Services.Autolayout -> VPC.FW: { + label: "egress denied" + style: {stroke-dash: 3; stroke: "#cc0000"} +} + +# SWP to NAT to Internet +VPC.SWP -> VPC.NAT: "" +VPC.NAT -> Internet: "" + +# Internet to SaaS destinations +Internet -> Sentry: "" +Internet -> ImageHosts: "" + +# SWP policy notes +PolicyNotes: { + shape: page + label: |md + ## SWP Policy Examples + + - Source = Exporter SA → allow *.sentry.io only + - Source = Backend SA → allow broader HTTPS (still block RFC1918/link-local) + - Source = Fetcher SA (optional) → general web allow with deny lists + | +} + +VPC.FW -> PolicyNotes: {style: {stroke-dash: 3}} diff --git a/docs/examples/cloud-run-egress/cloud-run-egress.svg b/docs/examples/cloud-run-egress/cloud-run-egress.svg new file mode 100644 index 0000000000..30e24a3ebe --- /dev/null +++ b/docs/examples/cloud-run-egress/cloud-run-egress.svg @@ -0,0 +1,869 @@ +

Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher

+
Cloud Run ServicesYour VPC (firewall / routes apply here)Internetsentry.ioAny image hosts

SWP Policy Examples

+
    +
  • Source = Exporter SA → allow *.sentry.io only
  • +
  • Source = Backend SA → allow broader HTTPS (still block RFC1918/link-local)
  • +
  • Source = Fetcher SA (optional) → general web allow with deny lists
  • +
+
Backend(tag: egress-web)Direct VPC egress: ALL_TRAFFICExporter(tag: egress-sentry)Direct VPC egress: ALL_TRAFFICAutolayout(tag: egress-none)Direct VPC egress: ALL_TRAFFICFetcher(tag: egress-fetcher)Direct VPC egress: ALL_TRAFFIC

Egress Firewall Policies

+
    +
  • Allow DNS (UDP/TCP 53) → 169.254.169.254
  • +
  • Deny Metadata (TCP 80/443) → 169.254.169.254
  • +
  • Allow → SWP VIP:80,443 (for tags: egress-web, egress-sentry, egress-fetcher)
  • +
  • Allow private RFC1918/required backends (optional)
  • +
  • Deny-All catch-all (per tag; e.g., egress-none)
  • +
+
Secure Web Proxy (SWP)Explicit HTTP/HTTPS proxy(domain/URL policy, logs)Cloud NATEgress IPs to Internet ingress (Run Invoker SA)ingress (Run Invoker SA)HTTPS (OIDC) call for image fetching via VPC FW → SWPvia VPC FW → SWPvia VPC FW → SWP egress denied + + + + + + + + +
diff --git a/e2etests/stable_test.go b/e2etests/stable_test.go index 33664eba0f..e1364fab1d 100644 --- a/e2etests/stable_test.go +++ b/e2etests/stable_test.go @@ -2060,6 +2060,16 @@ i am bottom right: { shape: text; near: bottom-right } poll the people -> results results -> unfavorable -> poll the people results -> favorable -> will of the people +`, + }, + { + name: "unicode_markdown_title", + justDagre: true, + script: `title: |md + # Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher +| { near: top-center } + +x -> y `, }, { diff --git a/e2etests/testdata/stable/unicode_markdown_title/dagre/board.exp.json b/e2etests/testdata/stable/unicode_markdown_title/dagre/board.exp.json new file mode 100644 index 0000000000..8b88399086 --- /dev/null +++ b/e2etests/testdata/stable/unicode_markdown_title/dagre/board.exp.json @@ -0,0 +1,232 @@ +{ + "name": "", + "config": { + "sketch": false, + "themeID": 0, + "darkThemeID": null, + "pad": null, + "center": null, + "layoutEngine": null + }, + "isFolderOnly": false, + "fontFamily": "SourceSansPro", + "monoFontFamily": "SourceCodePro", + "shapes": [ + { + "id": "title", + "type": "text", + "pos": { + "x": -484, + "y": -71 + }, + "width": 1022, + "height": 51, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "transparent", + "stroke": "N1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "# Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "markdown", + "color": "N1", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 1022, + "labelHeight": 51, + "zIndex": 0, + "level": 1 + }, + { + "id": "x", + "type": "rectangle", + "pos": { + "x": 1, + "y": 0 + }, + "width": 53, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "x", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 8, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + }, + { + "id": "y", + "type": "rectangle", + "pos": { + "x": 0, + "y": 166 + }, + "width": 54, + "height": 66, + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "borderRadius": 0, + "fill": "B6", + "stroke": "B1", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "y", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N1", + "italic": false, + "bold": true, + "underline": false, + "labelWidth": 9, + "labelHeight": 21, + "labelPosition": "INSIDE_MIDDLE_CENTER", + "zIndex": 0, + "level": 1 + } + ], + "connections": [ + { + "id": "(x -> y)[0]", + "src": "x", + "srcArrow": "none", + "dst": "y", + "dstArrow": "triangle", + "opacity": 1, + "strokeDash": 0, + "strokeWidth": 2, + "stroke": "B1", + "borderRadius": 10, + "label": "", + "fontSize": 16, + "fontFamily": "DEFAULT", + "language": "", + "color": "N2", + "italic": true, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "labelPosition": "", + "labelPercentage": 0, + "link": "", + "route": [ + { + "x": 27, + "y": 66 + }, + { + "x": 27, + "y": 106 + }, + { + "x": 27, + "y": 126 + }, + { + "x": 27, + "y": 166 + } + ], + "isCurve": true, + "animated": false, + "tooltip": "", + "icon": null, + "zIndex": 0 + } + ], + "root": { + "id": "", + "type": "", + "pos": { + "x": 0, + "y": 0 + }, + "width": 0, + "height": 0, + "opacity": 0, + "strokeDash": 0, + "strokeWidth": 0, + "borderRadius": 0, + "fill": "N7", + "stroke": "", + "animated": false, + "shadow": false, + "3d": false, + "multiple": false, + "double-border": false, + "tooltip": "", + "link": "", + "icon": null, + "iconPosition": "", + "blend": false, + "fields": null, + "methods": null, + "columns": null, + "label": "", + "fontSize": 0, + "fontFamily": "", + "language": "", + "color": "", + "italic": false, + "bold": false, + "underline": false, + "labelWidth": 0, + "labelHeight": 0, + "zIndex": 0, + "level": 0 + } +} diff --git a/e2etests/testdata/stable/unicode_markdown_title/dagre/sketch.exp.svg b/e2etests/testdata/stable/unicode_markdown_title/dagre/sketch.exp.svg new file mode 100644 index 0000000000..eb5f5fdae6 --- /dev/null +++ b/e2etests/testdata/stable/unicode_markdown_title/dagre/sketch.exp.svg @@ -0,0 +1,842 @@ +

Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher

+
xy + + +
\ No newline at end of file diff --git a/lib/textmeasure/textmeasure.go b/lib/textmeasure/textmeasure.go index 81c8fa5ba1..0f61e370f3 100644 --- a/lib/textmeasure/textmeasure.go +++ b/lib/textmeasure/textmeasure.go @@ -10,6 +10,7 @@ import ( "github.com/golang/freetype/truetype" "github.com/rivo/uniseg" + "golang.org/x/image/math/fixed" "oss.terrastruct.com/d2/d2renderers/d2fonts" "oss.terrastruct.com/d2/lib/geo" @@ -182,6 +183,27 @@ func (r *Ruler) addFontSize(font d2fonts.Font) { r.tabWidths[font] = atlas.glyph(' ').advance * TAB_SIZE } +func (t *Ruler) measureAdvanceWidth(font d2fonts.Font, s string) (float64, bool) { + sizelessFont := font + sizelessFont.Size = SIZELESS_FONT_SIZE + ttf, ok := t.ttfs[sizelessFont] + if !ok { + return 0, false + } + + var width fixed.Int26_6 + scale := fixed.I(font.Size) + for _, r := range s { + index := ttf.Index(r) + if index == 0 && r != 0 { + return 0, false + } + width += ttf.HMetric(scale, index).AdvanceWidth + } + + return float64(width) / 64, true +} + func (t *Ruler) scaleUnicode(w float64, font d2fonts.Font, s string) float64 { // Weird unicode stuff is going on when this is true // See https://github.com/rivo/uniseg#grapheme-clusters @@ -191,6 +213,13 @@ func (t *Ruler) scaleUnicode(w float64, font d2fonts.Font, s string) float64 { if uniseg.GraphemeClusterCount(s) != len(s) { for _, line := range strings.Split(s, "\n") { lineW, _ := t.MeasurePrecise(font, line) + // Font subsets omit layout tables such as GPOS. Use the original + // glyph advances so supported Unicode runes are not measured as the + // replacement glyph used by the fixed-size atlas. + if advanceWidth, ok := t.measureAdvanceWidth(font, line); ok { + w = math.Max(w, math.Max(lineW, advanceWidth)) + continue + } gr := uniseg.NewGraphemes(line) mono := d2fonts.SourceCodePro.Font(font.Size, font.Style) diff --git a/lib/textmeasure/textmeasure_test.go b/lib/textmeasure/textmeasure_test.go index 6ed0cba5a0..18d7b0229c 100644 --- a/lib/textmeasure/textmeasure_test.go +++ b/lib/textmeasure/textmeasure_test.go @@ -101,6 +101,7 @@ _italics are all measured correctly_ "`inline code`": {103, 24}, "`code`": {46, 24}, "`a`": {21, 24}, + `# Cloud Run Egress Architecture — Backend / Exporter / Autolayout / Fetcher`: {1022, 51}, } func TestTextMeasureMarkdown(t *testing.T) {