@leviosa-ai/detail-page-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { fmt } from "./writer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Geometry for the PDF (.ai) exporter: SVG path data and the primitive shapes
|
|
5
|
+
* our documents use, lowered onto the four path operators PDF actually has
|
|
6
|
+
* (``m``, ``l``, ``c``, ``h``).
|
|
7
|
+
*
|
|
8
|
+
* Everything else — quadratics, arcs, smooth-curve shorthands, circles,
|
|
9
|
+
* rounded rects — is converted to cubic Béziers here, because PDF has no
|
|
10
|
+
* ellipse or arc operator at all.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Max error of a circular arc approximated by one cubic Bézier (< 0.02%). */
|
|
14
|
+
const KAPPA = 0.5522847498307936;
|
|
15
|
+
|
|
16
|
+
/** Emit ``m``/``l``/``c``/``h`` operators for a rectangle, optionally rounded. */
|
|
17
|
+
export function rectPath(x: number, y: number, w: number, h: number, r = 0): string[] {
|
|
18
|
+
const radius = Math.max(0, Math.min(r, w / 2, h / 2));
|
|
19
|
+
if (!radius) return [`${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re`];
|
|
20
|
+
const k = radius * KAPPA;
|
|
21
|
+
const [x1, y1, x2, y2] = [x + radius, y + radius, x + w - radius, y + h - radius];
|
|
22
|
+
return [
|
|
23
|
+
`${fmt(x1)} ${fmt(y)} m`,
|
|
24
|
+
`${fmt(x2)} ${fmt(y)} l`,
|
|
25
|
+
`${fmt(x2 + k)} ${fmt(y)} ${fmt(x + w)} ${fmt(y1 - k)} ${fmt(x + w)} ${fmt(y1)} c`,
|
|
26
|
+
`${fmt(x + w)} ${fmt(y2)} l`,
|
|
27
|
+
`${fmt(x + w)} ${fmt(y2 + k)} ${fmt(x2 + k)} ${fmt(y + h)} ${fmt(x2)} ${fmt(y + h)} c`,
|
|
28
|
+
`${fmt(x1)} ${fmt(y + h)} l`,
|
|
29
|
+
`${fmt(x1 - k)} ${fmt(y + h)} ${fmt(x)} ${fmt(y2 + k)} ${fmt(x)} ${fmt(y2)} c`,
|
|
30
|
+
`${fmt(x)} ${fmt(y1)} l`,
|
|
31
|
+
`${fmt(x)} ${fmt(y1 - k)} ${fmt(x1 - k)} ${fmt(y)} ${fmt(x1)} ${fmt(y)} c`,
|
|
32
|
+
"h",
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Ellipse inscribed in the box, as four cubic Béziers. */
|
|
37
|
+
export function ellipsePath(cx: number, cy: number, rx: number, ry: number): string[] {
|
|
38
|
+
const kx = rx * KAPPA;
|
|
39
|
+
const ky = ry * KAPPA;
|
|
40
|
+
return [
|
|
41
|
+
`${fmt(cx + rx)} ${fmt(cy)} m`,
|
|
42
|
+
`${fmt(cx + rx)} ${fmt(cy + ky)} ${fmt(cx + kx)} ${fmt(cy + ry)} ${fmt(cx)} ${fmt(cy + ry)} c`,
|
|
43
|
+
`${fmt(cx - kx)} ${fmt(cy + ry)} ${fmt(cx - rx)} ${fmt(cy + ky)} ${fmt(cx - rx)} ${fmt(cy)} c`,
|
|
44
|
+
`${fmt(cx - rx)} ${fmt(cy - ky)} ${fmt(cx - kx)} ${fmt(cy - ry)} ${fmt(cx)} ${fmt(cy - ry)} c`,
|
|
45
|
+
`${fmt(cx + kx)} ${fmt(cy - ry)} ${fmt(cx + rx)} ${fmt(cy - ky)} ${fmt(cx + rx)} ${fmt(cy)} c`,
|
|
46
|
+
"h",
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function polygonPath(points: number[], close: boolean): string[] {
|
|
51
|
+
const ops: string[] = [];
|
|
52
|
+
for (let i = 0; i + 1 < points.length; i += 2) {
|
|
53
|
+
ops.push(`${fmt(points[i])} ${fmt(points[i + 1])} ${i === 0 ? "m" : "l"}`);
|
|
54
|
+
}
|
|
55
|
+
if (close && ops.length) ops.push("h");
|
|
56
|
+
return ops;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Parse an SVG ``points`` list ("1,2 3,4" / "1 2 3 4"). */
|
|
60
|
+
export function parsePoints(value: string): number[] {
|
|
61
|
+
return (value.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? []).map(Number);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
type Cursor = { x: number; y: number; startX: number; startY: number };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Elliptical arc → cubic Béziers (SVG implementation notes, F.6.5): the arc is
|
|
68
|
+
* un-rotated into a unit circle to recover the center and sweep, then split
|
|
69
|
+
* into ≤90° segments that a single cubic can hold.
|
|
70
|
+
*/
|
|
71
|
+
function arcToCurves(
|
|
72
|
+
x1: number,
|
|
73
|
+
y1: number,
|
|
74
|
+
rx: number,
|
|
75
|
+
ry: number,
|
|
76
|
+
angleDeg: number,
|
|
77
|
+
largeArc: boolean,
|
|
78
|
+
sweep: boolean,
|
|
79
|
+
x2: number,
|
|
80
|
+
y2: number,
|
|
81
|
+
): number[][] {
|
|
82
|
+
if (rx === 0 || ry === 0) return [[x2, y2, x2, y2, x2, y2]];
|
|
83
|
+
const phi = (angleDeg * Math.PI) / 180;
|
|
84
|
+
const cos = Math.cos(phi);
|
|
85
|
+
const sin = Math.sin(phi);
|
|
86
|
+
const dx = (x1 - x2) / 2;
|
|
87
|
+
const dy = (y1 - y2) / 2;
|
|
88
|
+
const x1p = cos * dx + sin * dy;
|
|
89
|
+
const y1p = -sin * dx + cos * dy;
|
|
90
|
+
let arx = Math.abs(rx);
|
|
91
|
+
let ary = Math.abs(ry);
|
|
92
|
+
// Radii too small to reach the endpoint are scaled up (SVG F.6.6).
|
|
93
|
+
const lambda = (x1p * x1p) / (arx * arx) + (y1p * y1p) / (ary * ary);
|
|
94
|
+
if (lambda > 1) {
|
|
95
|
+
const s = Math.sqrt(lambda);
|
|
96
|
+
arx *= s;
|
|
97
|
+
ary *= s;
|
|
98
|
+
}
|
|
99
|
+
const sign = largeArc === sweep ? -1 : 1;
|
|
100
|
+
const num =
|
|
101
|
+
arx * arx * ary * ary - arx * arx * y1p * y1p - ary * ary * x1p * x1p;
|
|
102
|
+
const den = arx * arx * y1p * y1p + ary * ary * x1p * x1p;
|
|
103
|
+
const co = sign * Math.sqrt(Math.max(0, num / den));
|
|
104
|
+
const cxp = (co * (arx * y1p)) / ary;
|
|
105
|
+
const cyp = (co * -(ary * x1p)) / arx;
|
|
106
|
+
const cx = cos * cxp - sin * cyp + (x1 + x2) / 2;
|
|
107
|
+
const cy = sin * cxp + cos * cyp + (y1 + y2) / 2;
|
|
108
|
+
|
|
109
|
+
const angle = (ux: number, uy: number, vx: number, vy: number) => {
|
|
110
|
+
const dot = ux * vx + uy * vy;
|
|
111
|
+
const len = Math.sqrt((ux * ux + uy * uy) * (vx * vx + vy * vy));
|
|
112
|
+
const a = Math.acos(Math.min(1, Math.max(-1, dot / (len || 1))));
|
|
113
|
+
return ux * vy - uy * vx < 0 ? -a : a;
|
|
114
|
+
};
|
|
115
|
+
const theta = angle(1, 0, (x1p - cxp) / arx, (y1p - cyp) / ary);
|
|
116
|
+
let delta = angle(
|
|
117
|
+
(x1p - cxp) / arx,
|
|
118
|
+
(y1p - cyp) / ary,
|
|
119
|
+
(-x1p - cxp) / arx,
|
|
120
|
+
(-y1p - cyp) / ary,
|
|
121
|
+
);
|
|
122
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI;
|
|
123
|
+
else if (sweep && delta < 0) delta += 2 * Math.PI;
|
|
124
|
+
|
|
125
|
+
const segments = Math.max(1, Math.ceil(Math.abs(delta / (Math.PI / 2))));
|
|
126
|
+
const step = delta / segments;
|
|
127
|
+
// Bézier handle length for a `step`-radian circular arc.
|
|
128
|
+
const t = ((4 / 3) * Math.tan(step / 4));
|
|
129
|
+
const curves: number[][] = [];
|
|
130
|
+
for (let i = 0; i < segments; i++) {
|
|
131
|
+
const a0 = theta + i * step;
|
|
132
|
+
const a1 = a0 + step;
|
|
133
|
+
const cosA0 = Math.cos(a0);
|
|
134
|
+
const sinA0 = Math.sin(a0);
|
|
135
|
+
const cosA1 = Math.cos(a1);
|
|
136
|
+
const sinA1 = Math.sin(a1);
|
|
137
|
+
const map = (px: number, py: number): [number, number] => [
|
|
138
|
+
cos * arx * px - sin * ary * py + cx,
|
|
139
|
+
sin * arx * px + cos * ary * py + cy,
|
|
140
|
+
];
|
|
141
|
+
const [c1x, c1y] = map(cosA0 - t * sinA0, sinA0 + t * cosA0);
|
|
142
|
+
const [c2x, c2y] = map(cosA1 + t * sinA1, sinA1 - t * cosA1);
|
|
143
|
+
const [ex, ey] = map(cosA1, sinA1);
|
|
144
|
+
curves.push([c1x, c1y, c2x, c2y, ex, ey]);
|
|
145
|
+
}
|
|
146
|
+
return curves;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* SVG path data → PDF path operators.
|
|
151
|
+
*
|
|
152
|
+
* Supports the full command set our decomposer emits (M m L l H h V v C c
|
|
153
|
+
* S s Q q A a Z z) — arcs alone account for the rounded corners of every
|
|
154
|
+
* speech bubble, so "just handle lines and curves" is not an option.
|
|
155
|
+
*/
|
|
156
|
+
export function svgPathToPdf(d: string): string[] {
|
|
157
|
+
const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
|
|
158
|
+
const ops: string[] = [];
|
|
159
|
+
const cur: Cursor = { x: 0, y: 0, startX: 0, startY: 0 };
|
|
160
|
+
let lastControl: [number, number] | null = null;
|
|
161
|
+
let command = "";
|
|
162
|
+
let i = 0;
|
|
163
|
+
|
|
164
|
+
const next = () => Number(tokens[i++]);
|
|
165
|
+
const moveTo = (x: number, y: number) => {
|
|
166
|
+
ops.push(`${fmt(x)} ${fmt(y)} m`);
|
|
167
|
+
cur.x = cur.startX = x;
|
|
168
|
+
cur.y = cur.startY = y;
|
|
169
|
+
};
|
|
170
|
+
const lineTo = (x: number, y: number) => {
|
|
171
|
+
ops.push(`${fmt(x)} ${fmt(y)} l`);
|
|
172
|
+
cur.x = x;
|
|
173
|
+
cur.y = y;
|
|
174
|
+
};
|
|
175
|
+
const curveTo = (
|
|
176
|
+
c1x: number,
|
|
177
|
+
c1y: number,
|
|
178
|
+
c2x: number,
|
|
179
|
+
c2y: number,
|
|
180
|
+
x: number,
|
|
181
|
+
y: number,
|
|
182
|
+
) => {
|
|
183
|
+
ops.push(
|
|
184
|
+
`${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(x)} ${fmt(y)} c`,
|
|
185
|
+
);
|
|
186
|
+
cur.x = x;
|
|
187
|
+
cur.y = y;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
while (i < tokens.length) {
|
|
191
|
+
const token = tokens[i];
|
|
192
|
+
if (/[a-zA-Z]/.test(token)) {
|
|
193
|
+
command = token;
|
|
194
|
+
i++;
|
|
195
|
+
// A bare Z carries no arguments.
|
|
196
|
+
if (command === "Z" || command === "z") {
|
|
197
|
+
ops.push("h");
|
|
198
|
+
cur.x = cur.startX;
|
|
199
|
+
cur.y = cur.startY;
|
|
200
|
+
lastControl = null;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
} else if (!command) {
|
|
204
|
+
break; // numbers before any command: malformed
|
|
205
|
+
} else if (command === "M") {
|
|
206
|
+
command = "L"; // repeated pairs after M are implicit lineTos
|
|
207
|
+
} else if (command === "m") {
|
|
208
|
+
command = "l";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const rel = command === command.toLowerCase();
|
|
212
|
+
const ox = rel ? cur.x : 0;
|
|
213
|
+
const oy = rel ? cur.y : 0;
|
|
214
|
+
|
|
215
|
+
switch (command.toUpperCase()) {
|
|
216
|
+
case "M":
|
|
217
|
+
moveTo(next() + ox, next() + oy);
|
|
218
|
+
lastControl = null;
|
|
219
|
+
break;
|
|
220
|
+
case "L":
|
|
221
|
+
lineTo(next() + ox, next() + oy);
|
|
222
|
+
lastControl = null;
|
|
223
|
+
break;
|
|
224
|
+
case "H":
|
|
225
|
+
lineTo(next() + ox, cur.y);
|
|
226
|
+
lastControl = null;
|
|
227
|
+
break;
|
|
228
|
+
case "V":
|
|
229
|
+
lineTo(cur.x, next() + oy);
|
|
230
|
+
lastControl = null;
|
|
231
|
+
break;
|
|
232
|
+
case "C": {
|
|
233
|
+
const c1x = next() + ox;
|
|
234
|
+
const c1y = next() + oy;
|
|
235
|
+
const c2x = next() + ox;
|
|
236
|
+
const c2y = next() + oy;
|
|
237
|
+
curveTo(c1x, c1y, c2x, c2y, next() + ox, next() + oy);
|
|
238
|
+
lastControl = [c2x, c2y];
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
case "S": {
|
|
242
|
+
// Smooth cubic: first control point mirrors the previous one.
|
|
243
|
+
const [rx, ry] = lastControl
|
|
244
|
+
? [2 * cur.x - lastControl[0], 2 * cur.y - lastControl[1]]
|
|
245
|
+
: [cur.x, cur.y];
|
|
246
|
+
const c2x = next() + ox;
|
|
247
|
+
const c2y = next() + oy;
|
|
248
|
+
curveTo(rx, ry, c2x, c2y, next() + ox, next() + oy);
|
|
249
|
+
lastControl = [c2x, c2y];
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "Q": {
|
|
253
|
+
// PDF has no quadratic operator — raise it to a cubic.
|
|
254
|
+
const qx = next() + ox;
|
|
255
|
+
const qy = next() + oy;
|
|
256
|
+
const x = next() + ox;
|
|
257
|
+
const y = next() + oy;
|
|
258
|
+
curveTo(
|
|
259
|
+
cur.x + (2 / 3) * (qx - cur.x),
|
|
260
|
+
cur.y + (2 / 3) * (qy - cur.y),
|
|
261
|
+
x + (2 / 3) * (qx - x),
|
|
262
|
+
y + (2 / 3) * (qy - y),
|
|
263
|
+
x,
|
|
264
|
+
y,
|
|
265
|
+
);
|
|
266
|
+
lastControl = [qx, qy];
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
case "T": {
|
|
270
|
+
// Annotated because `lastControl` is re-assigned from these below —
|
|
271
|
+
// inferring both at once would be circular.
|
|
272
|
+
const qx: number = lastControl ? 2 * cur.x - lastControl[0] : cur.x;
|
|
273
|
+
const qy: number = lastControl ? 2 * cur.y - lastControl[1] : cur.y;
|
|
274
|
+
const x = next() + ox;
|
|
275
|
+
const y = next() + oy;
|
|
276
|
+
const sx = cur.x;
|
|
277
|
+
const sy = cur.y;
|
|
278
|
+
curveTo(
|
|
279
|
+
sx + (2 / 3) * (qx - sx),
|
|
280
|
+
sy + (2 / 3) * (qy - sy),
|
|
281
|
+
x + (2 / 3) * (qx - x),
|
|
282
|
+
y + (2 / 3) * (qy - y),
|
|
283
|
+
x,
|
|
284
|
+
y,
|
|
285
|
+
);
|
|
286
|
+
lastControl = [qx, qy];
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
case "A": {
|
|
290
|
+
const rx = next();
|
|
291
|
+
const ry = next();
|
|
292
|
+
const rot = next();
|
|
293
|
+
const largeArc = next() !== 0;
|
|
294
|
+
const sweep = next() !== 0;
|
|
295
|
+
const x = next() + ox;
|
|
296
|
+
const y = next() + oy;
|
|
297
|
+
for (const c of arcToCurves(cur.x, cur.y, rx, ry, rot, largeArc, sweep, x, y)) {
|
|
298
|
+
curveTo(c[0], c[1], c[2], c[3], c[4], c[5]);
|
|
299
|
+
}
|
|
300
|
+
lastControl = null;
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
default:
|
|
304
|
+
i++; // unknown command: skip a token rather than spin
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return ops;
|
|
309
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type { ExportElement } from "../document-model";
|
|
2
|
+
import {
|
|
3
|
+
ellipsePath,
|
|
4
|
+
parsePoints,
|
|
5
|
+
polygonPath,
|
|
6
|
+
rectPath,
|
|
7
|
+
svgPathToPdf,
|
|
8
|
+
} from "./geometry";
|
|
9
|
+
import {
|
|
10
|
+
parsePaint,
|
|
11
|
+
rgbOps,
|
|
12
|
+
type ResourcePool,
|
|
13
|
+
type RgbColor,
|
|
14
|
+
type ShadingStop,
|
|
15
|
+
} from "./resources";
|
|
16
|
+
import { showTextOps } from "./text-ops";
|
|
17
|
+
import { fmt } from "./writer";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Inline SVG (``svg``-type elements: decomposer icons, speech bubbles, charts,
|
|
21
|
+
* badges) → PDF vector operators.
|
|
22
|
+
*
|
|
23
|
+
* These carry a real share of every detail page, so rasterizing them would
|
|
24
|
+
* hand Illustrator a flat picture instead of editable artwork. The vocabulary
|
|
25
|
+
* the decomposer emits is small and closed — path, circle, rect, line,
|
|
26
|
+
* polyline, polygon, ellipse, g, text, and linear/radial gradients — which is
|
|
27
|
+
* exactly what this covers. Anything else (patterns, <use>) is skipped rather
|
|
28
|
+
* than drawn wrong.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type InlineSvgEnv = {
|
|
32
|
+
pool: ResourcePool;
|
|
33
|
+
/** Same measurement hook the text exporters use, for ``<text>`` inside icons. */
|
|
34
|
+
measure: (el: ExportElement, text: string) => number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
type Style = Record<string, string>;
|
|
38
|
+
|
|
39
|
+
const INHERITED = [
|
|
40
|
+
"fill",
|
|
41
|
+
"stroke",
|
|
42
|
+
"stroke-width",
|
|
43
|
+
"stroke-linecap",
|
|
44
|
+
"stroke-linejoin",
|
|
45
|
+
"stroke-dasharray",
|
|
46
|
+
"fill-rule",
|
|
47
|
+
"fill-opacity",
|
|
48
|
+
"stroke-opacity",
|
|
49
|
+
"opacity",
|
|
50
|
+
"font-family",
|
|
51
|
+
"font-size",
|
|
52
|
+
"font-weight",
|
|
53
|
+
"font-style",
|
|
54
|
+
"letter-spacing",
|
|
55
|
+
"text-anchor",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
function readStyle(el: Element, parent: Style): Style {
|
|
59
|
+
const style: Style = { ...parent };
|
|
60
|
+
for (const key of INHERITED) {
|
|
61
|
+
const value = el.getAttribute(key);
|
|
62
|
+
if (value) style[key] = value;
|
|
63
|
+
}
|
|
64
|
+
// A `style` attribute wins over presentation attributes.
|
|
65
|
+
for (const rule of (el.getAttribute("style") ?? "").split(";")) {
|
|
66
|
+
const [key, value] = rule.split(":");
|
|
67
|
+
if (key && value) style[key.trim()] = value.trim();
|
|
68
|
+
}
|
|
69
|
+
return style;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const num = (value: string | null, fallback = 0): number => {
|
|
73
|
+
const n = parseFloat(value ?? "");
|
|
74
|
+
return Number.isFinite(n) ? n : fallback;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const rgb = (value: string): RgbColor | null => parsePaint(value)?.color ?? null;
|
|
78
|
+
|
|
79
|
+
/** Bounding box of emitted path ops. Curve control points inflate it slightly —
|
|
80
|
+
* good enough for mapping an objectBoundingBox gradient, its only use. */
|
|
81
|
+
function opsBounds(ops: string[]): { x: number; y: number; w: number; h: number } {
|
|
82
|
+
let minX = Infinity;
|
|
83
|
+
let minY = Infinity;
|
|
84
|
+
let maxX = -Infinity;
|
|
85
|
+
let maxY = -Infinity;
|
|
86
|
+
for (const op of ops) {
|
|
87
|
+
const numbers = (op.match(/-?\d*\.?\d+/g) ?? []).map(Number);
|
|
88
|
+
// `re` carries (x, y, w, h); every other op carries coordinate pairs.
|
|
89
|
+
if (op.endsWith("re") && numbers.length === 4) {
|
|
90
|
+
minX = Math.min(minX, numbers[0]);
|
|
91
|
+
minY = Math.min(minY, numbers[1]);
|
|
92
|
+
maxX = Math.max(maxX, numbers[0] + numbers[2]);
|
|
93
|
+
maxY = Math.max(maxY, numbers[1] + numbers[3]);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (let i = 0; i + 1 < numbers.length; i += 2) {
|
|
97
|
+
minX = Math.min(minX, numbers[i]);
|
|
98
|
+
maxX = Math.max(maxX, numbers[i]);
|
|
99
|
+
minY = Math.min(minY, numbers[i + 1]);
|
|
100
|
+
maxY = Math.max(maxY, numbers[i + 1]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (!Number.isFinite(minX)) return { x: 0, y: 0, w: 0, h: 0 };
|
|
104
|
+
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function gradientStops(node: Element): ShadingStop[] {
|
|
108
|
+
const stops: ShadingStop[] = [];
|
|
109
|
+
for (const stop of Array.from(node.querySelectorAll("stop"))) {
|
|
110
|
+
const color = rgb(stop.getAttribute("stop-color") ?? "#000");
|
|
111
|
+
if (!color) continue;
|
|
112
|
+
const offsetAttr = stop.getAttribute("offset") ?? "0";
|
|
113
|
+
const offset = offsetAttr.endsWith("%")
|
|
114
|
+
? parseFloat(offsetAttr) / 100
|
|
115
|
+
: parseFloat(offsetAttr);
|
|
116
|
+
stops.push({ offset: Number.isFinite(offset) ? offset : 0, color });
|
|
117
|
+
}
|
|
118
|
+
return stops;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve a ``url(#id)`` paint to a shading resource, mapping an
|
|
123
|
+
* objectBoundingBox gradient onto the shape's own box.
|
|
124
|
+
*/
|
|
125
|
+
function shadingFor(
|
|
126
|
+
paint: string,
|
|
127
|
+
root: Element,
|
|
128
|
+
bounds: { x: number; y: number; w: number; h: number },
|
|
129
|
+
env: InlineSvgEnv,
|
|
130
|
+
): string | null {
|
|
131
|
+
const id = paint.match(/^url\(#([^)]+)\)$/)?.[1];
|
|
132
|
+
if (!id) return null;
|
|
133
|
+
const node = root.querySelector(`#${CSS.escape(id)}`);
|
|
134
|
+
if (!node) return null;
|
|
135
|
+
const stops = gradientStops(node);
|
|
136
|
+
if (!stops.length) return null;
|
|
137
|
+
const userSpace = node.getAttribute("gradientUnits") === "userSpaceOnUse";
|
|
138
|
+
const mapX = (v: number) => (userSpace ? v : bounds.x + v * bounds.w);
|
|
139
|
+
const mapY = (v: number) => (userSpace ? v : bounds.y + v * bounds.h);
|
|
140
|
+
|
|
141
|
+
if (node.tagName.toLowerCase() === "radialgradient") {
|
|
142
|
+
const cx = mapX(num(node.getAttribute("cx"), userSpace ? 0 : 0.5));
|
|
143
|
+
const cy = mapY(num(node.getAttribute("cy"), userSpace ? 0 : 0.5));
|
|
144
|
+
const r = num(node.getAttribute("r"), userSpace ? 0 : 0.5);
|
|
145
|
+
const radius = userSpace ? r : r * Math.max(bounds.w, bounds.h);
|
|
146
|
+
return env.pool.shading({ kind: "radial", cx, cy, r: radius, stops });
|
|
147
|
+
}
|
|
148
|
+
return env.pool.shading({
|
|
149
|
+
kind: "axial",
|
|
150
|
+
x0: mapX(num(node.getAttribute("x1"), 0)),
|
|
151
|
+
y0: mapY(num(node.getAttribute("y1"), 0)),
|
|
152
|
+
x1: mapX(num(node.getAttribute("x2"), userSpace ? 0 : 1)),
|
|
153
|
+
y1: mapY(num(node.getAttribute("y2"), 0)),
|
|
154
|
+
stops,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Paint a built path with the resolved fill/stroke of the current style. */
|
|
159
|
+
function paintOps(
|
|
160
|
+
path: string[],
|
|
161
|
+
style: Style,
|
|
162
|
+
root: Element,
|
|
163
|
+
env: InlineSvgEnv,
|
|
164
|
+
): string[] {
|
|
165
|
+
if (!path.length) return [];
|
|
166
|
+
const fill = style.fill ?? "#000"; // SVG's initial fill really is black
|
|
167
|
+
const stroke = style.stroke ?? "none";
|
|
168
|
+
const strokeWidth = num(style["stroke-width"], 1);
|
|
169
|
+
const strokePaint = stroke !== "none" && strokeWidth > 0 ? parsePaint(stroke) : null;
|
|
170
|
+
const hasStroke = strokePaint !== null;
|
|
171
|
+
const fillPaint = fill !== "none" && !fill.startsWith("url(") ? parsePaint(fill) : null;
|
|
172
|
+
const hasFill = fill !== "none" && (fillPaint !== null || fill.startsWith("url("));
|
|
173
|
+
if (!hasFill && !hasStroke) return [];
|
|
174
|
+
|
|
175
|
+
const evenOdd = style["fill-rule"] === "evenodd";
|
|
176
|
+
const ops: string[] = ["q"];
|
|
177
|
+
|
|
178
|
+
const opacity = num(style.opacity, 1);
|
|
179
|
+
const fillAlpha = opacity * num(style["fill-opacity"], 1) * (fillPaint?.alpha ?? 1);
|
|
180
|
+
const strokeAlpha = opacity * num(style["stroke-opacity"], 1) * (strokePaint?.alpha ?? 1);
|
|
181
|
+
if (fillAlpha < 1 || strokeAlpha < 1) {
|
|
182
|
+
ops.push(`/${env.pool.alpha(fillAlpha, strokeAlpha)} gs`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (strokePaint) {
|
|
186
|
+
ops.push(`${rgbOps(strokePaint.color)} RG`, `${fmt(strokeWidth)} w`);
|
|
187
|
+
const cap = { butt: 0, round: 1, square: 2 }[style["stroke-linecap"] ?? "butt"] ?? 0;
|
|
188
|
+
const join = { miter: 0, round: 1, bevel: 2 }[style["stroke-linejoin"] ?? "miter"] ?? 0;
|
|
189
|
+
ops.push(`${cap} J`, `${join} j`);
|
|
190
|
+
const dash = style["stroke-dasharray"];
|
|
191
|
+
if (dash && dash !== "none") {
|
|
192
|
+
ops.push(`[${parsePoints(dash).map(fmt).join(" ")}] 0 d`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// A gradient fill has no colour operator: clip to the path and run the shading.
|
|
197
|
+
const gradient = fill.startsWith("url(")
|
|
198
|
+
? shadingFor(fill, root, opsBounds(path), env)
|
|
199
|
+
: null;
|
|
200
|
+
if (gradient) {
|
|
201
|
+
ops.push("q", ...path, evenOdd ? "W* n" : "W n", `/${gradient} sh`, "Q");
|
|
202
|
+
if (strokePaint) ops.push(...path, "S");
|
|
203
|
+
ops.push("Q");
|
|
204
|
+
return ops;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (fillPaint) ops.push(`${rgbOps(fillPaint.color)} rg`);
|
|
208
|
+
ops.push(...path);
|
|
209
|
+
ops.push(
|
|
210
|
+
fillPaint && strokePaint
|
|
211
|
+
? evenOdd
|
|
212
|
+
? "B*"
|
|
213
|
+
: "B"
|
|
214
|
+
: fillPaint
|
|
215
|
+
? evenOdd
|
|
216
|
+
? "f*"
|
|
217
|
+
: "f"
|
|
218
|
+
: "S",
|
|
219
|
+
"Q",
|
|
220
|
+
);
|
|
221
|
+
return ops;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function textOps(node: Element, style: Style, env: InlineSvgEnv): string[] {
|
|
225
|
+
const text = (node.textContent ?? "").trim();
|
|
226
|
+
if (!text) return [];
|
|
227
|
+
const fontSize = num(style["font-size"], 16);
|
|
228
|
+
const weight = /bold/i.test(style["font-weight"] ?? "")
|
|
229
|
+
? 700
|
|
230
|
+
: num(style["font-weight"], 400);
|
|
231
|
+
const letterSpacing = num(style["letter-spacing"], 0);
|
|
232
|
+
const synthetic: ExportElement = {
|
|
233
|
+
fontFamily: (style["font-family"] ?? "Pretendard").split(",")[0].replace(/['"]/g, "").trim(),
|
|
234
|
+
fontSize,
|
|
235
|
+
fontWeight: weight,
|
|
236
|
+
fontStyle: style["font-style"],
|
|
237
|
+
// The measurement hook takes letterSpacing in em, like Canvas does.
|
|
238
|
+
letterSpacing: fontSize ? letterSpacing / fontSize : 0,
|
|
239
|
+
};
|
|
240
|
+
const width = env.measure(synthetic, text);
|
|
241
|
+
const anchor = style["text-anchor"];
|
|
242
|
+
let x = num(node.getAttribute("x"), 0);
|
|
243
|
+
if (anchor === "middle") x -= width / 2;
|
|
244
|
+
else if (anchor === "end") x -= width;
|
|
245
|
+
|
|
246
|
+
const color = rgb(style.fill ?? "#000") ?? [0, 0, 0];
|
|
247
|
+
return [
|
|
248
|
+
"q",
|
|
249
|
+
`${rgbOps(color)} rg`,
|
|
250
|
+
...showTextOps({
|
|
251
|
+
fontRes: env.pool.font({
|
|
252
|
+
family: synthetic.fontFamily ?? "Pretendard",
|
|
253
|
+
weight,
|
|
254
|
+
italic: /italic/i.test(style["font-style"] ?? ""),
|
|
255
|
+
}),
|
|
256
|
+
fontSize,
|
|
257
|
+
x,
|
|
258
|
+
baseline: num(node.getAttribute("y"), 0),
|
|
259
|
+
text,
|
|
260
|
+
measure: (t) => env.measure(synthetic, t),
|
|
261
|
+
}),
|
|
262
|
+
"Q",
|
|
263
|
+
];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function nodeOps(node: Element, parent: Style, root: Element, env: InlineSvgEnv): string[] {
|
|
267
|
+
const style = readStyle(node, parent);
|
|
268
|
+
const tag = node.tagName.toLowerCase();
|
|
269
|
+
|
|
270
|
+
switch (tag) {
|
|
271
|
+
case "g": {
|
|
272
|
+
const ops: string[] = ["q"];
|
|
273
|
+
const opacity = num(node.getAttribute("opacity"), 1);
|
|
274
|
+
if (opacity < 1) ops.push(`/${env.pool.alpha(opacity)} gs`);
|
|
275
|
+
for (const child of Array.from(node.children)) {
|
|
276
|
+
ops.push(...nodeOps(child, style, root, env));
|
|
277
|
+
}
|
|
278
|
+
ops.push("Q");
|
|
279
|
+
return ops;
|
|
280
|
+
}
|
|
281
|
+
case "path":
|
|
282
|
+
return paintOps(svgPathToPdf(node.getAttribute("d") ?? ""), style, root, env);
|
|
283
|
+
case "rect":
|
|
284
|
+
return paintOps(
|
|
285
|
+
rectPath(
|
|
286
|
+
num(node.getAttribute("x")),
|
|
287
|
+
num(node.getAttribute("y")),
|
|
288
|
+
num(node.getAttribute("width")),
|
|
289
|
+
num(node.getAttribute("height")),
|
|
290
|
+
num(node.getAttribute("rx")) || num(node.getAttribute("ry")),
|
|
291
|
+
),
|
|
292
|
+
style,
|
|
293
|
+
root,
|
|
294
|
+
env,
|
|
295
|
+
);
|
|
296
|
+
case "circle": {
|
|
297
|
+
const r = num(node.getAttribute("r"));
|
|
298
|
+
return paintOps(
|
|
299
|
+
ellipsePath(num(node.getAttribute("cx")), num(node.getAttribute("cy")), r, r),
|
|
300
|
+
style,
|
|
301
|
+
root,
|
|
302
|
+
env,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
case "ellipse":
|
|
306
|
+
return paintOps(
|
|
307
|
+
ellipsePath(
|
|
308
|
+
num(node.getAttribute("cx")),
|
|
309
|
+
num(node.getAttribute("cy")),
|
|
310
|
+
num(node.getAttribute("rx")),
|
|
311
|
+
num(node.getAttribute("ry")),
|
|
312
|
+
),
|
|
313
|
+
style,
|
|
314
|
+
root,
|
|
315
|
+
env,
|
|
316
|
+
);
|
|
317
|
+
case "line":
|
|
318
|
+
return paintOps(
|
|
319
|
+
polygonPath(
|
|
320
|
+
[
|
|
321
|
+
num(node.getAttribute("x1")),
|
|
322
|
+
num(node.getAttribute("y1")),
|
|
323
|
+
num(node.getAttribute("x2")),
|
|
324
|
+
num(node.getAttribute("y2")),
|
|
325
|
+
],
|
|
326
|
+
false,
|
|
327
|
+
),
|
|
328
|
+
// A line has no interior; a stray inherited fill would paint a triangle.
|
|
329
|
+
{ ...style, fill: "none" },
|
|
330
|
+
root,
|
|
331
|
+
env,
|
|
332
|
+
);
|
|
333
|
+
case "polyline":
|
|
334
|
+
case "polygon":
|
|
335
|
+
return paintOps(
|
|
336
|
+
polygonPath(parsePoints(node.getAttribute("points") ?? ""), tag === "polygon"),
|
|
337
|
+
tag === "polyline" ? { ...style, fill: style.fill ?? "none" } : style,
|
|
338
|
+
root,
|
|
339
|
+
env,
|
|
340
|
+
);
|
|
341
|
+
case "text":
|
|
342
|
+
return textOps(node, style, env);
|
|
343
|
+
case "defs":
|
|
344
|
+
case "lineargradient":
|
|
345
|
+
case "radialgradient":
|
|
346
|
+
return []; // referenced on demand, never drawn in place
|
|
347
|
+
default:
|
|
348
|
+
return [];
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Place an inline SVG inside its element box: viewBox → box mapping (the
|
|
354
|
+
* ``preserveAspectRatio`` our exporters use), then the drawing operators.
|
|
355
|
+
* Returns null when the markup is not usable, so the caller can fall back to
|
|
356
|
+
* embedding the icon as a bitmap.
|
|
357
|
+
*/
|
|
358
|
+
export function inlineSvgOps(
|
|
359
|
+
markup: string,
|
|
360
|
+
box: { x: number; y: number; width: number; height: number },
|
|
361
|
+
fit: string,
|
|
362
|
+
env: InlineSvgEnv,
|
|
363
|
+
): string[] | null {
|
|
364
|
+
if (typeof DOMParser === "undefined") return null;
|
|
365
|
+
const parsed = new DOMParser().parseFromString(markup, "image/svg+xml");
|
|
366
|
+
const root = parsed.documentElement;
|
|
367
|
+
if (!root || root.tagName.toLowerCase() !== "svg") return null;
|
|
368
|
+
|
|
369
|
+
const viewBox = parsePoints(root.getAttribute("viewBox") ?? "");
|
|
370
|
+
const [vx, vy, vw, vh] =
|
|
371
|
+
viewBox.length === 4
|
|
372
|
+
? viewBox
|
|
373
|
+
: [0, 0, num(root.getAttribute("width")), num(root.getAttribute("height"))];
|
|
374
|
+
if (!(vw > 0) || !(vh > 0)) return null;
|
|
375
|
+
|
|
376
|
+
const sx = box.width / vw;
|
|
377
|
+
const sy = box.height / vh;
|
|
378
|
+
const scale =
|
|
379
|
+
fit === "fill" ? null : fit === "cover" ? Math.max(sx, sy) : Math.min(sx, sy);
|
|
380
|
+
const [scaleX, scaleY] = scale === null ? [sx, sy] : [scale, scale];
|
|
381
|
+
// Centered, like the SVG exporter's xMidYMid.
|
|
382
|
+
const tx = box.x + (box.width - vw * scaleX) / 2 - vx * scaleX;
|
|
383
|
+
const ty = box.y + (box.height - vh * scaleY) / 2 - vy * scaleY;
|
|
384
|
+
|
|
385
|
+
const ops: string[] = [
|
|
386
|
+
"q",
|
|
387
|
+
// `cover` can overflow the box, exactly as it does on the canvas.
|
|
388
|
+
...(fit === "cover"
|
|
389
|
+
? [`${fmt(box.x)} ${fmt(box.y)} ${fmt(box.width)} ${fmt(box.height)} re`, "W n"]
|
|
390
|
+
: []),
|
|
391
|
+
`${fmt(scaleX)} 0 0 ${fmt(scaleY)} ${fmt(tx)} ${fmt(ty)} cm`,
|
|
392
|
+
];
|
|
393
|
+
const baseStyle = readStyle(root, {});
|
|
394
|
+
for (const child of Array.from(root.children)) {
|
|
395
|
+
ops.push(...nodeOps(child, baseStyle, root, env));
|
|
396
|
+
}
|
|
397
|
+
ops.push("Q");
|
|
398
|
+
return ops;
|
|
399
|
+
}
|