From 71cdbd8f528f131609acb1587f27ad387cb579ac Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Thu, 27 Aug 2026 15:30:22 -0700 Subject: [PATCH 1/3] fix(block-ui): center PDF net labels svg2pdf ignores dominant-baseline and reads alignment-baseline. Mirror the value during export without overriding explicit alignment. --- .../src/rendering/export-pdf.test.ts | 45 ++++++++++++++++++- system-block-ui/src/rendering/export-pdf.ts | 18 ++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/system-block-ui/src/rendering/export-pdf.test.ts b/system-block-ui/src/rendering/export-pdf.test.ts index 3796950f..21c44577 100644 --- a/system-block-ui/src/rendering/export-pdf.test.ts +++ b/system-block-ui/src/rendering/export-pdf.test.ts @@ -1,6 +1,25 @@ import { describe, expect, test } from "bun:test"; -import { calculateSchematicPdfPageLayout } from "./export-pdf"; +import { + calculateSchematicPdfPageLayout, + copyDominantBaselinesForSvg2Pdf, +} from "./export-pdf"; + +const baselineElement = ( + initialAttributes: Readonly>, +) => { + const attributes = new Map(Object.entries(initialAttributes)); + return { + attributes, + element: { + getAttribute: (name: string) => attributes.get(name) ?? null, + hasAttribute: (name: string) => attributes.has(name), + setAttribute: (name: string, value: string) => { + attributes.set(name, value); + }, + }, + }; +}; describe("schematic PDF page layout", () => { test("stretches a schematic over an entire A4 landscape page", () => { @@ -37,3 +56,27 @@ describe("schematic PDF page layout", () => { ).toThrow("pageHeightMm must be a positive finite number"); }); }); + +describe("SVG text baseline compatibility", () => { + test("copies dominant-baseline for svg2pdf without overriding explicit alignment", () => { + const centered = baselineElement({ "dominant-baseline": "central" }); + const explicitlyAligned = baselineElement({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + + copyDominantBaselinesForSvg2Pdf([ + centered.element, + explicitlyAligned.element, + ]); + + expect(Object.fromEntries(centered.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + }); + expect(Object.fromEntries(explicitlyAligned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + }); +}); diff --git a/system-block-ui/src/rendering/export-pdf.ts b/system-block-ui/src/rendering/export-pdf.ts index 758d94a4..c15f43c3 100644 --- a/system-block-ui/src/rendering/export-pdf.ts +++ b/system-block-ui/src/rendering/export-pdf.ts @@ -49,6 +49,23 @@ interface PreparedSheet { element: Element; } +type SvgTextBaselineElement = Pick< + Element, + "getAttribute" | "hasAttribute" | "setAttribute" +>; + +export const copyDominantBaselinesForSvg2Pdf = ( + elements: Iterable, +): void => { + for (const element of elements) { + if (element.hasAttribute("alignment-baseline")) continue; + const dominantBaseline = element.getAttribute("dominant-baseline"); + if (dominantBaseline) { + element.setAttribute("alignment-baseline", dominantBaseline); + } + } +}; + const parseSvg = (svg: string): Element => { if (typeof DOMParser === "undefined") { throw new Error("PDF export requires a browser DOMParser"); @@ -175,6 +192,7 @@ const prepareSheets = (input: SchematicPdfInput): PreparedSheet[] => { throw new TypeError(`Schematic sheet ${index + 1} has no SVG content`); } const element = parseSvg(sheet.svg); + copyDominantBaselinesForSvg2Pdf(element.querySelectorAll("text, tspan")); const dimensions = getSvgDimensions(element); normalizeSvgViewport(element, dimensions); return { element }; From 578023d26eb31d8b8b2c2e4702351e08d05d7d4e Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Thu, 27 Aug 2026 15:38:06 -0700 Subject: [PATCH 2/3] fix(block-ui): match SVG baseline position jsPDF renders its middle baseline above the browser SVG position. Apply an em-relative correction without overriding explicit dy values. --- system-block-ui/src/rendering/export-pdf.test.ts | 15 +++++++++++++-- system-block-ui/src/rendering/export-pdf.ts | 10 ++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/system-block-ui/src/rendering/export-pdf.test.ts b/system-block-ui/src/rendering/export-pdf.test.ts index 21c44577..919fed94 100644 --- a/system-block-ui/src/rendering/export-pdf.test.ts +++ b/system-block-ui/src/rendering/export-pdf.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { calculateSchematicPdfPageLayout, - copyDominantBaselinesForSvg2Pdf, + normalizeTextBaselinesForSvg2Pdf, } from "./export-pdf"; const baselineElement = ( @@ -64,19 +64,30 @@ describe("SVG text baseline compatibility", () => { "dominant-baseline": "central", "alignment-baseline": "hanging", }); + const explicitlyPositioned = baselineElement({ + "dominant-baseline": "central", + dy: "0.2em", + }); - copyDominantBaselinesForSvg2Pdf([ + normalizeTextBaselinesForSvg2Pdf([ centered.element, explicitlyAligned.element, + explicitlyPositioned.element, ]); expect(Object.fromEntries(centered.attributes)).toEqual({ "dominant-baseline": "central", "alignment-baseline": "central", + dy: "0.175em", }); expect(Object.fromEntries(explicitlyAligned.attributes)).toEqual({ "dominant-baseline": "central", "alignment-baseline": "hanging", }); + expect(Object.fromEntries(explicitlyPositioned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + dy: "0.2em", + }); }); }); diff --git a/system-block-ui/src/rendering/export-pdf.ts b/system-block-ui/src/rendering/export-pdf.ts index c15f43c3..273873c8 100644 --- a/system-block-ui/src/rendering/export-pdf.ts +++ b/system-block-ui/src/rendering/export-pdf.ts @@ -54,7 +54,7 @@ type SvgTextBaselineElement = Pick< "getAttribute" | "hasAttribute" | "setAttribute" >; -export const copyDominantBaselinesForSvg2Pdf = ( +export const normalizeTextBaselinesForSvg2Pdf = ( elements: Iterable, ): void => { for (const element of elements) { @@ -62,6 +62,12 @@ export const copyDominantBaselinesForSvg2Pdf = ( const dominantBaseline = element.getAttribute("dominant-baseline"); if (dominantBaseline) { element.setAttribute("alignment-baseline", dominantBaseline); + // jsPDF's middle baseline renders Helvetica slightly above the SVG + // central baseline. Apply the measured em-relative correction while + // preserving any explicit author positioning. + if (dominantBaseline === "central" && !element.hasAttribute("dy")) { + element.setAttribute("dy", "0.175em"); + } } } }; @@ -192,7 +198,7 @@ const prepareSheets = (input: SchematicPdfInput): PreparedSheet[] => { throw new TypeError(`Schematic sheet ${index + 1} has no SVG content`); } const element = parseSvg(sheet.svg); - copyDominantBaselinesForSvg2Pdf(element.querySelectorAll("text, tspan")); + normalizeTextBaselinesForSvg2Pdf(element.querySelectorAll("text, tspan")); const dimensions = getSvgDimensions(element); normalizeSvgViewport(element, dimensions); return { element }; From 33604d4777da412db6d758926e4e1a0eb632de71 Mon Sep 17 00:00:00 2001 From: Sangit Manandhar Date: Thu, 27 Aug 2026 15:47:04 -0700 Subject: [PATCH 3/3] fix(block-ui): tune PDF label baseline Keep the SVG compatibility logic isolated and apply the visually verified 0.08em central-baseline correction. --- .../src/rendering/export-pdf.test.ts | 56 +---------------- system-block-ui/src/rendering/export-pdf.ts | 24 +------- ...rmalize-text-baselines-for-svg2pdf.test.ts | 61 +++++++++++++++++++ .../normalize-text-baselines-for-svg2pdf.ts | 25 ++++++++ 4 files changed, 88 insertions(+), 78 deletions(-) create mode 100644 system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts create mode 100644 system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts diff --git a/system-block-ui/src/rendering/export-pdf.test.ts b/system-block-ui/src/rendering/export-pdf.test.ts index 919fed94..3796950f 100644 --- a/system-block-ui/src/rendering/export-pdf.test.ts +++ b/system-block-ui/src/rendering/export-pdf.test.ts @@ -1,25 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { - calculateSchematicPdfPageLayout, - normalizeTextBaselinesForSvg2Pdf, -} from "./export-pdf"; - -const baselineElement = ( - initialAttributes: Readonly>, -) => { - const attributes = new Map(Object.entries(initialAttributes)); - return { - attributes, - element: { - getAttribute: (name: string) => attributes.get(name) ?? null, - hasAttribute: (name: string) => attributes.has(name), - setAttribute: (name: string, value: string) => { - attributes.set(name, value); - }, - }, - }; -}; +import { calculateSchematicPdfPageLayout } from "./export-pdf"; describe("schematic PDF page layout", () => { test("stretches a schematic over an entire A4 landscape page", () => { @@ -56,38 +37,3 @@ describe("schematic PDF page layout", () => { ).toThrow("pageHeightMm must be a positive finite number"); }); }); - -describe("SVG text baseline compatibility", () => { - test("copies dominant-baseline for svg2pdf without overriding explicit alignment", () => { - const centered = baselineElement({ "dominant-baseline": "central" }); - const explicitlyAligned = baselineElement({ - "dominant-baseline": "central", - "alignment-baseline": "hanging", - }); - const explicitlyPositioned = baselineElement({ - "dominant-baseline": "central", - dy: "0.2em", - }); - - normalizeTextBaselinesForSvg2Pdf([ - centered.element, - explicitlyAligned.element, - explicitlyPositioned.element, - ]); - - expect(Object.fromEntries(centered.attributes)).toEqual({ - "dominant-baseline": "central", - "alignment-baseline": "central", - dy: "0.175em", - }); - expect(Object.fromEntries(explicitlyAligned.attributes)).toEqual({ - "dominant-baseline": "central", - "alignment-baseline": "hanging", - }); - expect(Object.fromEntries(explicitlyPositioned.attributes)).toEqual({ - "dominant-baseline": "central", - "alignment-baseline": "central", - dy: "0.2em", - }); - }); -}); diff --git a/system-block-ui/src/rendering/export-pdf.ts b/system-block-ui/src/rendering/export-pdf.ts index 273873c8..3e82c82c 100644 --- a/system-block-ui/src/rendering/export-pdf.ts +++ b/system-block-ui/src/rendering/export-pdf.ts @@ -1,6 +1,7 @@ import { jsPDF } from "jspdf"; import { svg2pdf } from "svg2pdf.js"; import { downloadBlob } from "./download-blob"; +import { normalizeTextBaselinesForSvg2Pdf } from "./normalize-text-baselines-for-svg2pdf"; export { downloadBlob } from "./download-blob"; @@ -49,29 +50,6 @@ interface PreparedSheet { element: Element; } -type SvgTextBaselineElement = Pick< - Element, - "getAttribute" | "hasAttribute" | "setAttribute" ->; - -export const normalizeTextBaselinesForSvg2Pdf = ( - elements: Iterable, -): void => { - for (const element of elements) { - if (element.hasAttribute("alignment-baseline")) continue; - const dominantBaseline = element.getAttribute("dominant-baseline"); - if (dominantBaseline) { - element.setAttribute("alignment-baseline", dominantBaseline); - // jsPDF's middle baseline renders Helvetica slightly above the SVG - // central baseline. Apply the measured em-relative correction while - // preserving any explicit author positioning. - if (dominantBaseline === "central" && !element.hasAttribute("dy")) { - element.setAttribute("dy", "0.175em"); - } - } - } -}; - const parseSvg = (svg: string): Element => { if (typeof DOMParser === "undefined") { throw new Error("PDF export requires a browser DOMParser"); diff --git a/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts new file mode 100644 index 00000000..d2b677f0 --- /dev/null +++ b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeTextBaselinesForSvg2Pdf } from "./normalize-text-baselines-for-svg2pdf"; + +const createBaselineElement = ( + initialAttributes: Readonly>, +) => { + const attributes = new Map(Object.entries(initialAttributes)); + + return { + attributes, + element: { + getAttribute: (name: string) => attributes.get(name) ?? null, + hasAttribute: (name: string) => attributes.has(name), + setAttribute: (name: string, attributeValue: string) => { + attributes.set(name, attributeValue); + }, + }, + }; +}; + +describe("SVG text baseline compatibility", () => { + test("maps the dominant baseline and calibrates central text", () => { + const centered = createBaselineElement({ + "dominant-baseline": "central", + }); + + normalizeTextBaselinesForSvg2Pdf([centered.element]); + + expect(Object.fromEntries(centered.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + dy: "0.08em", + }); + }); + + test("preserves explicit alignment and positioning", () => { + const explicitlyAligned = createBaselineElement({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + const explicitlyPositioned = createBaselineElement({ + "dominant-baseline": "central", + dy: "0.2em", + }); + + normalizeTextBaselinesForSvg2Pdf([ + explicitlyAligned.element, + explicitlyPositioned.element, + ]); + + expect(Object.fromEntries(explicitlyAligned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "hanging", + }); + expect(Object.fromEntries(explicitlyPositioned.attributes)).toEqual({ + "dominant-baseline": "central", + "alignment-baseline": "central", + dy: "0.2em", + }); + }); +}); diff --git a/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts new file mode 100644 index 00000000..1f9e3d9c --- /dev/null +++ b/system-block-ui/src/rendering/normalize-text-baselines-for-svg2pdf.ts @@ -0,0 +1,25 @@ +const SVG2PDF_CENTRAL_BASELINE_DY = "0.08em"; + +type SvgTextBaselineElement = Pick< + Element, + "getAttribute" | "hasAttribute" | "setAttribute" +>; + +export const normalizeTextBaselinesForSvg2Pdf = ( + elements: Iterable, +): void => { + for (const element of elements) { + if (element.hasAttribute("alignment-baseline")) continue; + + const dominantBaseline = element.getAttribute("dominant-baseline"); + if (!dominantBaseline) continue; + + element.setAttribute("alignment-baseline", dominantBaseline); + + // jsPDF places its middle baseline slightly above the browser SVG + // central baseline. Preserve author positioning when compensating for it. + if (dominantBaseline === "central" && !element.hasAttribute("dy")) { + element.setAttribute("dy", SVG2PDF_CENTRAL_BASELINE_DY); + } + } +};