Skip to content

Commit cfdaa1b

Browse files
committed
feat: close clipPath/marker/use/visibility/miterlimit gaps
- <clipPath> now supports <g> and <use> children, including nested combinations, with any transform/offset along the way baked directly into the resulting path coordinates (new transformPathData in geometry/path.ts). A <clipPath> that resolves to zero usable paths now correctly clips away everything instead of drawing unclipped -- these were previously silently skipped with no warning at all. - marker-end on a closed subpath (path/polygon ending in Z) now lands at the subpath's start point with a bisected angle, per spec, instead of the last explicit point before Z. A single closed subpath now gets a separate coincident marker-start and marker-end, not just one. - <use> referencing a plain nested <svg> now gets the same width/height override treatment as referencing a <symbol> (own x/y ignored, fallback chain to 100% of the viewport). - display="none" now drops an element's subtree entirely, including when reached indirectly through <use>; visibility="hidden"/"collapse" now skips drawing while still walking children (so a nested visibility="visible" can turn itself back on), both previously unhandled anywhere. - stroke-miterlimit is now resolved and applied, defaulting to SVG's own 4 rather than leaving it to the PDF writer's default of 10 -- @libpdf/core already exposed the operator, it just wasn't wired up. New fixtures: clip-path-use-and-group.svg, marker-closed-shape.svg, use-nested-svg.svg, display-and-visibility.svg, stroke-miterlimit.svg. Documented two resvg (visual-test reference renderer) rendering gaps hit along the way -- <g> inside <clipPath> -- in svgEmbed.visual.test.ts so they're recognized quickly if hit again, rather than mistaken for svg-pdf regressions. docs/supported-features.md updated to match: clipPath's remaining caveat is now only unsupported content types (<text>/<image>), not transform support.
1 parent b4acb0e commit cfdaa1b

20 files changed

Lines changed: 556 additions & 124 deletions

docs/supported-features.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,13 @@ Each item below notes _why_ it's limited, using four categories: a **PDF format
5959
- **Patterns and markers with rotation** _(@libpdf/core limitation)_: if a `<pattern>` is reached through a rotated or skewed transform (its own `patternTransform`, or an ancestor `<g>` that's rotated/skewed), it's skipped with a warning instead of being drawn incorrectly. `@libpdf/core`'s tiling-pattern API can only position a repeating pattern using plain, non-rotated numbers — it has no way to hand it a rotation matrix.
6060
- **Pattern and marker content is limited to solid colors** _(@libpdf/core limitation)_: the inside of a `<pattern>` or `<marker>` can only contain shapes with a plain solid fill/stroke — not another gradient, another pattern, text, or an image nested inside it. Anything like that is skipped individually with a warning. Both are built internally from a bare list of drawing operators with no resource dictionary of their own, so there's nowhere to register a nested font/image/gradient/pattern. (This only affects what's _inside_ the pattern/marker; a marker's overall placement — its rotation, scale, position — is unrestricted.)
6161
- **Opacity inside a pattern/marker isn't honored** _(@libpdf/core limitation)_: `fill-opacity`/`stroke-opacity`/`opacity` used _inside_ a `<pattern>` or `<marker>`'s own content don't currently have any effect (drawn fully opaque instead, with a warning) — same root cause as above: no resource dictionary means no place to attach the transparency setting. Opacity on the _shape the pattern/marker is applied to_ works fine; this limitation is only about opacity used inside the pattern/marker's own artwork.
62+
- **`<clipPath>` content is limited to shapes, `<g>`, and `<use>`** _(not yet implemented)_: plain shapes work directly; a `<g>` wrapping several shapes (to union them together) and a `<use>` reusing another shape's geometry both work too, including nested combinations of the two, with any `transform`/offset along the way applied correctly. Something else inside a `<clipPath>` (like `<text>` or `<image>`) is skipped with a warning instead of being drawn wrong. If every child of a `<clipPath>` ends up skipped this way, the clip region is empty and nothing draws at all (an empty clip region hides everything, per spec) rather than drawing unclipped.
6263
- **`@font-face` only works when the font data is embedded directly** _(design choice)_ in the SVG (`src: url(data:...)`) — a `@font-face` pointing at an external URL instead is skipped with a warning, for the same safety reason external images aren't fetched automatically (see [Images](#images) above). Matching an SVG's requested `font-family`/`font-weight`/`font-style` against an available `@font-face` is a simple, case-insensitive text match, not the more flexible matching real browsers do.
6364
- **Per-character positioning** (`dx`/`dy`/`rotate` with a list of values on `<text>`/`<tspan>`, letting you nudge or rotate individual characters instead of a whole line) is supported.
6465
- **`word-spacing` may not visibly do anything with a custom embedded font** _(PDF format limitation)_ — this is a quirk of the PDF format itself, not something svg-pdf can work around: PDF's word-spacing feature only works with a certain kind of font encoding that standard fonts always use, but embedded custom fonts typically don't. `letter-spacing` isn't affected and works either way.
65-
- **A link's clickable area is a rectangle** _(PDF format limitation)_ (a box around everything it wraps — shapes, text, images, even invisible ones), not an exact outline of the shape — so a link wrapped around a star icon gets a rectangular click target around the star. PDF link annotations are always rectangles; there's no way to give one an arbitrary outline, the same way most SVG-to-PDF tools handle it.
66+
- **A link's clickable area is a rectangle** _(PDF format limitation)_ (a box around everything it wraps — shapes, text, images, even invisible ones), not an exact outline of the shape — so a link wrapped around a star icon gets a rectangular click target around the star. PDF link annotations are always rectangles; there's no way to give one an arbitrary outline, the same way most SVG to PDF tools handle it.
6667
- **A link pointing at the same page (`href="#fragment"`) is skipped with a warning** _(design choice)_, since each SVG becomes one standalone PDF page with nothing else to jump to. PDF itself supports internal links between pages/destinations — this would need svg-pdf to support multiple pages or multiple linked SVGs first. Any other kind of link (an external URL, `mailto:`, `tel:`, etc.) is used as-is.
68+
- **An `<image>` whose data is itself an SVG (a `data:image/svg+xml...` URI, or an `.svg` file fetched via `fetchImage`) isn't drawn as crisp vector content** _(not yet implemented)_: svg-pdf always treats `<image>` bytes as a raster picture to embed, never as a nested SVG document to render. In Node this SVG-as-image case fails to decode and is skipped with a warning (there's no `OffscreenCanvas` to rasterize it with either); this is a real but comparatively uncommon pattern next to plain PNG/JPEG image data.
6769
- **Text-along-a-path (`<textPath>`)** _(not yet implemented, for what's left)_: each character is individually positioned and rotated to follow the path's curve, starting at a given offset (`startOffset`, as a plain number or a percentage) along the path, respecting the path's own `pathLength` if it has one, and shifted by `text-anchor` the same way regular text is. It always uses a standard font rather than a custom one from `fetchFont`/`@font-face`. Two things aren't supported yet:
6870
- `textLength` (stretching/compressing the text to fit an exact length) works in its default mode, which only adjusts the spacing _between_ characters. `lengthAdjust="spacingAndGlyphs"`, which would also resize the characters themselves, isn't supported — it falls back to spacing-only with a warning.
6971
- Nesting a `<tspan>` inside a `<textPath>` isn't supported — its children are skipped with a warning, and only the `<textPath>`'s own direct text is used.

packages/core/src/__tests__/markerVertices.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,30 @@ describe('computeMarkerVertices', () => {
4141

4242
it('closes a square: start uses only the outgoing edge, ignoring the Z closing tangent', () => {
4343
const vertices = computeMarkerVertices('M 0 0 L 10 0 L 10 10 L 0 10 Z');
44-
expect(vertices).toHaveLength(4);
4544
expect(vertices[0]).toMatchObject({ x: 0, y: 0, type: 'start' });
4645
// Outgoing edge (0,0)->(10,0) points along +x, not the Z-closing edge's -y direction.
4746
expect(vertices[0].angle).toBeCloseTo(0);
4847
});
4948

49+
it('places a coincident marker-end at the closing point of a single closed subpath, bisecting the closing and first edges', () => {
50+
const vertices = computeMarkerVertices('M 0 0 L 10 0 L 10 10 L 0 10 Z');
51+
// 4 corners -> start + 3 mid (the corner right before Z is now mid, not end) + a separately-appended end at the shared start/end point.
52+
expect(vertices.map((v) => v.type)).toEqual(['start', 'mid', 'mid', 'mid', 'end']);
53+
const end = vertices[vertices.length - 1];
54+
expect(end).toMatchObject({ x: 0, y: 0 });
55+
// Closing edge (0,10)->(0,0) is (0,-1); first edge (0,0)->(10,0) is (1,0); bisector is -45°.
56+
expect(end.angle).toBeCloseTo(-Math.PI / 4);
57+
});
58+
5059
it('treats every vertex between the first and last as marker-mid, including across multiple subpaths', () => {
5160
const vertices = computeMarkerVertices('M 0 0 L 10 0 Z M 20 20 L 30 20');
5261
expect(vertices.map((v) => v.type)).toEqual(['start', 'mid', 'mid', 'end']);
5362
});
63+
64+
it("does not duplicate marker-end when a closed subpath isn't the very first vertex of the path", () => {
65+
// The last subpath's own start (20,20) is a distinct vertex from the path's overall first vertex (0,0), so no coincidence/duplication applies.
66+
const vertices = computeMarkerVertices('M 0 0 L 10 0 M 20 20 L 30 20 L 30 30 Z');
67+
expect(vertices.map((v) => v.type)).toEqual(['start', 'mid', 'end', 'mid', 'mid']);
68+
expect(vertices.filter((v) => v.type === 'end')).toHaveLength(1);
69+
});
5470
});

packages/core/src/__tests__/svgCodec.test.ts

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,42 @@ describe('parseSvgDocument', () => {
303303
expect(scalePush.matrix.d).toBe(2);
304304
});
305305

306+
it('gives <use> referencing a nested <svg> the same width/height override treatment as referencing a <symbol>', () => {
307+
const doc = parseSvgDocument(
308+
'<svg viewBox="0 0 100 100"><defs><svg id="box" viewBox="0 0 10 10"><rect width="10" height="10" fill="#000"/></svg></defs><use href="#box" x="0" y="0" width="20" height="20"/></svg>',
309+
);
310+
expect(
311+
doc.instructions.some(
312+
(i) => i.type === 'pushMatrix' && i.matrix.a === 2 && i.matrix.d === 2,
313+
),
314+
).toBe(true);
315+
});
316+
317+
it("falls back to the referenced <svg>'s own width/height when <use> omits them, instead of drawing unscaled", () => {
318+
const doc = parseSvgDocument(
319+
'<svg viewBox="0 0 100 100"><defs><svg id="box" viewBox="0 0 10 10" width="20" height="20"><rect width="10" height="10" fill="#000"/></svg></defs><use href="#box"/></svg>',
320+
);
321+
const scalePush = doc.instructions.find(
322+
(i) => i.type === 'pushMatrix' && i.matrix.a === i.matrix.d && i.matrix.a !== 1,
323+
);
324+
if (!scalePush || scalePush.type !== 'pushMatrix') throw new Error('unreachable');
325+
expect(scalePush.matrix.a).toBe(2);
326+
expect(scalePush.matrix.d).toBe(2);
327+
});
328+
329+
it("ignores the referenced <svg>'s own x/y, using only the <use>'s x/y (per spec)", () => {
330+
const doc = parseSvgDocument(
331+
'<svg viewBox="0 0 100 100"><defs><svg id="box" x="99" y="99" width="10" height="10"><rect width="10" height="10" fill="#000"/></svg></defs><use href="#box" x="5" y="5"/></svg>',
332+
);
333+
const offsetPush = doc.instructions.find(
334+
(i) => i.type === 'pushMatrix' && i.matrix.e === 5 && i.matrix.f === 5,
335+
);
336+
expect(offsetPush).toBeDefined();
337+
expect(doc.instructions.some((i) => i.type === 'pushMatrix' && i.matrix.e === 99)).toBe(
338+
false,
339+
);
340+
});
341+
306342
it('falls back to 100% of the current viewport when neither <use> nor <symbol> specify width/height', () => {
307343
const doc = parseSvgDocument(
308344
'<svg viewBox="0 0 40 20"><defs><symbol id="box" viewBox="0 0 10 10"><rect width="10" height="10" fill="#000"/></symbol></defs><use href="#box"/></svg>',
@@ -744,22 +780,84 @@ describe('parseSvgDocument (clip-path)', () => {
744780
});
745781
});
746782

747-
it('skips a clip-path child that has its own transform, with a warning', () => {
783+
it('renders nothing (not "unclipped") when no clip child survives, with a warning', () => {
784+
const doc = parseSvgDocument(
785+
'<svg viewBox="0 0 100 100"><defs><clipPath id="c"><text>nope</text></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
786+
);
787+
// A valid <clipPath> that resolves to zero usable paths clips away everything, per spec -- the opposite of "no clip-path at all".
788+
expect(doc.instructions).toHaveLength(0);
789+
expect(doc.warnings.some((w) => w.includes('<text>'))).toBe(true);
790+
});
791+
792+
it("bakes a clip child's own transform directly into its path coordinates", () => {
748793
const doc = parseSvgDocument(
749794
'<svg viewBox="0 0 100 100"><defs><clipPath id="c"><circle cx="0" cy="0" r="20" transform="translate(50,50)"/></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
750795
);
751-
// No usable clip child survived, so clipping is skipped entirely (fail-safe: draw unclipped).
752-
expect(doc.instructions.map((i) => i.type)).toEqual(['shape']);
753-
expect(doc.warnings.some((w) => w.includes('own transform'))).toBe(true);
796+
expect(doc.instructions.map((i) => i.type)).toEqual(['pushClip', 'shape', 'popClip']);
797+
const pushClip = doc.instructions[0];
798+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
799+
expect(pushClip.paths).toHaveLength(1);
800+
// Circle's own first point (20,0) shifted by translate(50,50) -> (70,50).
801+
expect(pushClip.paths[0].startsWith('M 70 50')).toBe(true);
754802
});
755803

756-
it('warns and skips when clip-path target is missing', () => {
804+
it('warns and skips (draws unclipped) when clip-path target is missing', () => {
757805
const doc = parseSvgDocument(
758806
'<svg viewBox="0 0 100 100"><rect width="10" height="10" clip-path="url(#missing)"/></svg>',
759807
);
808+
// A broken url(#id) reference means "as if clip-path weren't specified" per spec -- unlike an empty-but-valid <clipPath>, this draws normally.
760809
expect(doc.instructions.map((i) => i.type)).toEqual(['shape']);
761810
expect(doc.warnings.some((w) => w.includes('not found'))).toBe(true);
762811
});
812+
813+
it('resolves a <use> referencing shared shape geometry inside a <clipPath>', () => {
814+
const doc = parseSvgDocument(
815+
'<svg viewBox="0 0 100 100"><defs><circle id="shape" cx="50" cy="50" r="20"/><clipPath id="c"><use href="#shape"/></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
816+
);
817+
expect(doc.instructions.map((i) => i.type)).toEqual(['pushClip', 'shape', 'popClip']);
818+
const pushClip = doc.instructions[0];
819+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
820+
expect(pushClip.paths).toHaveLength(1);
821+
});
822+
823+
it("bakes a <use>'s own x/y offset into the referenced shape's path inside a <clipPath>", () => {
824+
const doc = parseSvgDocument(
825+
'<svg viewBox="0 0 100 100"><defs><circle id="shape" cx="0" cy="0" r="20"/><clipPath id="c"><use href="#shape" x="10"/></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
826+
);
827+
const pushClip = doc.instructions[0];
828+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
829+
// Circle's own first point (20,0) shifted by the <use>'s x="10" -> (30,0).
830+
expect(pushClip.paths[0].startsWith('M 30 0')).toBe(true);
831+
});
832+
833+
it('recurses into a <g> wrapping multiple clip shapes', () => {
834+
const doc = parseSvgDocument(
835+
'<svg viewBox="0 0 100 100"><defs><clipPath id="c"><g><circle cx="30" cy="30" r="10"/><rect x="50" y="50" width="10" height="10"/></g></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
836+
);
837+
const pushClip = doc.instructions[0];
838+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
839+
expect(pushClip.paths).toHaveLength(2);
840+
});
841+
842+
it("bakes a <g>'s own transform into its children's paths inside a <clipPath>", () => {
843+
const doc = parseSvgDocument(
844+
'<svg viewBox="0 0 100 100"><defs><clipPath id="c"><g transform="translate(10,10)"><circle cx="30" cy="30" r="10"/></g></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
845+
);
846+
const pushClip = doc.instructions[0];
847+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
848+
// Circle's own first point (cx+r, cy) = (40,30) shifted by the <g>'s translate(10,10) -> (50,40).
849+
expect(pushClip.paths[0].startsWith('M 50 40')).toBe(true);
850+
});
851+
852+
it("bakes nested <g>/<use> transforms and a referenced shape's own transform together", () => {
853+
const doc = parseSvgDocument(
854+
'<svg viewBox="0 0 100 100"><defs><circle id="shape" cx="0" cy="0" r="10" transform="translate(5,0)"/><clipPath id="c"><g transform="translate(20,0)"><use href="#shape" x="0" y="20"/></g></clipPath></defs><rect width="100" height="100" clip-path="url(#c)"/></svg>',
855+
);
856+
const pushClip = doc.instructions[0];
857+
if (pushClip.type !== 'pushClip') throw new Error('unreachable');
858+
// Circle's own first point (10,0) -> shape's own transform (+5,0)=(15,0) -> <use> x/y (0,20)=(15,20) -> <g> transform (+20,0)=(35,20).
859+
expect(pushClip.paths[0].startsWith('M 35 20')).toBe(true);
860+
});
763861
});
764862

765863
describe('parseSvgDocument (text)', () => {
@@ -1536,3 +1634,57 @@ describe.skipIf(!hasFixtures)('parseSvgDocument (real-world-shaped fixtures)', (
15361634
expect(doc.instructions.some((i) => i.type === 'text')).toBe(true);
15371635
});
15381636
});
1637+
1638+
describe('parseSvgDocument (display/visibility)', () => {
1639+
it('omits an element and its whole subtree for display="none"', () => {
1640+
const doc = parseSvgDocument(
1641+
'<svg viewBox="0 0 100 100"><g display="none"><rect width="10" height="10"/><rect width="20" height="20"/></g><circle cx="50" cy="50" r="5"/></svg>',
1642+
);
1643+
expect(shapesOf(doc)).toHaveLength(1);
1644+
});
1645+
1646+
it('omits an element reached indirectly through a <use> reference for display="none"', () => {
1647+
const doc = parseSvgDocument(
1648+
'<svg viewBox="0 0 100 100"><defs><rect id="r" display="none" width="10" height="10"/></defs><use href="#r"/></svg>',
1649+
);
1650+
expect(shapesOf(doc)).toHaveLength(0);
1651+
});
1652+
1653+
it('draws nothing for visibility="hidden" but still walks children (a descendant can turn itself back on)', () => {
1654+
const doc = parseSvgDocument(
1655+
'<svg viewBox="0 0 100 100"><g visibility="hidden"><rect width="10" height="10"/><rect visibility="visible" width="20" height="20"/></g></svg>',
1656+
);
1657+
expect(shapesOf(doc)).toHaveLength(1);
1658+
expect(shapesOf(doc)[0].d).toContain('20');
1659+
});
1660+
1661+
it('inherits visibility="hidden" down to a plain <text> run', () => {
1662+
const doc = parseSvgDocument(
1663+
'<svg viewBox="0 0 100 100"><text visibility="hidden">hidden</text></svg>',
1664+
);
1665+
expect(textsOf(doc)).toHaveLength(0);
1666+
});
1667+
1668+
it('treats visibility="collapse" the same as "hidden"', () => {
1669+
const doc = parseSvgDocument(
1670+
'<svg viewBox="0 0 100 100"><rect visibility="collapse" width="10" height="10"/></svg>',
1671+
);
1672+
expect(shapesOf(doc)).toHaveLength(0);
1673+
});
1674+
});
1675+
1676+
describe('parseSvgDocument (stroke-miterlimit)', () => {
1677+
it("defaults to 4 (SVG default), not left to the PDF writer's own default", () => {
1678+
const doc = parseSvgDocument(
1679+
'<svg viewBox="0 0 100 100"><rect width="10" height="10" stroke="#000"/></svg>',
1680+
);
1681+
expect(shapesOf(doc)[0].miterLimit).toBe(4);
1682+
});
1683+
1684+
it('resolves an explicit stroke-miterlimit and inherits it to children', () => {
1685+
const doc = parseSvgDocument(
1686+
'<svg viewBox="0 0 100 100"><g stroke-miterlimit="8"><rect width="10" height="10" stroke="#000"/></g></svg>',
1687+
);
1688+
expect(shapesOf(doc)[0].miterLimit).toBe(8);
1689+
});
1690+
});

0 commit comments

Comments
 (0)