@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.
Files changed (190) hide show
  1. package/components/cardnews/color-input.tsx +380 -0
  2. package/components/detail-page/ai-generate-panel.tsx +763 -0
  3. package/components/detail-page/annotation-canvas.tsx +860 -0
  4. package/components/detail-page/annotation-dialog.tsx +397 -0
  5. package/components/detail-page/bubble-tail-overlay.tsx +209 -0
  6. package/components/detail-page/canvas-context-menu.tsx +234 -0
  7. package/components/detail-page/canvas-observer.tsx +68 -0
  8. package/components/detail-page/canvas-overlay-host.tsx +44 -0
  9. package/components/detail-page/canvas-store-facade.ts +51 -0
  10. package/components/detail-page/chart-inspector.tsx +531 -0
  11. package/components/detail-page/design-reference-picker.tsx +383 -0
  12. package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
  13. package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
  14. package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
  15. package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
  16. package/components/detail-page/detail-page-charts-panel.tsx +104 -0
  17. package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
  18. package/components/detail-page/detail-page-download-dialog.tsx +524 -0
  19. package/components/detail-page/detail-page-editor.tsx +974 -0
  20. package/components/detail-page/detail-page-elements-panel.tsx +117 -0
  21. package/components/detail-page/detail-page-font-picker.tsx +231 -0
  22. package/components/detail-page/detail-page-history-buttons.tsx +87 -0
  23. package/components/detail-page/detail-page-host-context.tsx +465 -0
  24. package/components/detail-page/detail-page-icons-panel.tsx +251 -0
  25. package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
  26. package/components/detail-page/detail-page-layers-panel.tsx +618 -0
  27. package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
  28. package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
  29. package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
  30. package/components/detail-page/detail-page-pages-panel.tsx +299 -0
  31. package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
  32. package/components/detail-page/detail-page-photos-panel.tsx +257 -0
  33. package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
  34. package/components/detail-page/detail-page-qr-panel.tsx +137 -0
  35. package/components/detail-page/detail-page-references-panel.tsx +166 -0
  36. package/components/detail-page/detail-page-selection.ts +44 -0
  37. package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
  38. package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
  39. package/components/detail-page/detail-page-tables-panel.tsx +106 -0
  40. package/components/detail-page/detail-page-text-panel.tsx +179 -0
  41. package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
  42. package/components/detail-page/edit-quota-ui.tsx +150 -0
  43. package/components/detail-page/editor-history.ts +56 -0
  44. package/components/detail-page/editor-hotkeys.tsx +165 -0
  45. package/components/detail-page/element-recents-strip.tsx +105 -0
  46. package/components/detail-page/element-rects.ts +82 -0
  47. package/components/detail-page/fill-control.tsx +261 -0
  48. package/components/detail-page/find-replace-panel.tsx +252 -0
  49. package/components/detail-page/generation-progress-fill.tsx +57 -0
  50. package/components/detail-page/gif-animator.tsx +184 -0
  51. package/components/detail-page/gif-effect-picker.tsx +136 -0
  52. package/components/detail-page/group-drill-in.tsx +219 -0
  53. package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
  54. package/components/detail-page/hover-highlight-overlay.tsx +111 -0
  55. package/components/detail-page/hovered-layer.ts +34 -0
  56. package/components/detail-page/inspector-controls.tsx +175 -0
  57. package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
  58. package/components/detail-page/nested-selection-overlay.tsx +364 -0
  59. package/components/detail-page/panel-search-input.tsx +49 -0
  60. package/components/detail-page/prompt-edit-panel.tsx +146 -0
  61. package/components/detail-page/section-height-handle.tsx +276 -0
  62. package/components/detail-page/section-reauthor-controller.tsx +210 -0
  63. package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
  64. package/components/detail-page/spec-resize-absorber.tsx +149 -0
  65. package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
  66. package/components/detail-page/table-canvas-overlay.tsx +707 -0
  67. package/components/detail-page/table-inspector.tsx +586 -0
  68. package/components/ui/button.tsx +64 -0
  69. package/components/ui/command.tsx +175 -0
  70. package/components/ui/dialog.tsx +167 -0
  71. package/components/ui/label.tsx +24 -0
  72. package/components/ui/select.tsx +190 -0
  73. package/components/ui/slider.tsx +63 -0
  74. package/components/ui/switch.tsx +35 -0
  75. package/components/ui/tooltip.tsx +57 -0
  76. package/config/detail-page-bundle-font-tags.json +39 -0
  77. package/config/detail-page-fonts.json +373 -0
  78. package/index.ts +24 -0
  79. package/lib/cardnews/font-loader.ts +306 -0
  80. package/lib/detail-page/animation-sniff.ts +101 -0
  81. package/lib/detail-page/asset-bytes-url.ts +70 -0
  82. package/lib/detail-page/basic-shapes.ts +603 -0
  83. package/lib/detail-page/canvas-menu.ts +182 -0
  84. package/lib/detail-page/chart/defaults.ts +283 -0
  85. package/lib/detail-page/chart/format.ts +66 -0
  86. package/lib/detail-page/chart/normalize.ts +149 -0
  87. package/lib/detail-page/chart/palette.ts +85 -0
  88. package/lib/detail-page/chart/paste.ts +82 -0
  89. package/lib/detail-page/chart/preview.ts +78 -0
  90. package/lib/detail-page/chart/render.ts +188 -0
  91. package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
  92. package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
  93. package/lib/detail-page/chart/renderers/donut.ts +186 -0
  94. package/lib/detail-page/chart/renderers/gauge.ts +96 -0
  95. package/lib/detail-page/chart/renderers/line.ts +143 -0
  96. package/lib/detail-page/chart/renderers/shared.ts +263 -0
  97. package/lib/detail-page/chart/renderers/stack.ts +121 -0
  98. package/lib/detail-page/chart/sync.ts +92 -0
  99. package/lib/detail-page/chart/types.ts +145 -0
  100. package/lib/detail-page/css-color.ts +34 -0
  101. package/lib/detail-page/data-gif-payload.ts +167 -0
  102. package/lib/detail-page/design-reference.ts +244 -0
  103. package/lib/detail-page/distribute.ts +89 -0
  104. package/lib/detail-page/editor-routes.ts +10 -0
  105. package/lib/detail-page/element-recents.ts +167 -0
  106. package/lib/detail-page/find-replace.ts +120 -0
  107. package/lib/detail-page/format-painter.ts +159 -0
  108. package/lib/detail-page/gif-frames.ts +216 -0
  109. package/lib/detail-page/gif-timeline.ts +65 -0
  110. package/lib/detail-page/group-action.ts +62 -0
  111. package/lib/detail-page/hangul-roman.ts +58 -0
  112. package/lib/detail-page/icon-curated.ts +290 -0
  113. package/lib/detail-page/icon-fuzzy.ts +250 -0
  114. package/lib/detail-page/icon-keywords.generated.ts +3766 -0
  115. package/lib/detail-page/icon-keywords.ko.ts +524 -0
  116. package/lib/detail-page/icon-search.ts +257 -0
  117. package/lib/detail-page/icons.ts +67 -0
  118. package/lib/detail-page/image-credit.ts +121 -0
  119. package/lib/detail-page/image-data-uri.ts +29 -0
  120. package/lib/detail-page/insert-image.ts +75 -0
  121. package/lib/detail-page/insert-shape.ts +49 -0
  122. package/lib/detail-page/korean-dictionary.ts +187 -0
  123. package/lib/detail-page/layer-move.ts +257 -0
  124. package/lib/detail-page/new-product-draft-store.ts +105 -0
  125. package/lib/detail-page/new-product-draft.ts +283 -0
  126. package/lib/detail-page/personal-images-refresh.ts +30 -0
  127. package/lib/detail-page/product-source.ts +587 -0
  128. package/lib/detail-page/qr-code.ts +214 -0
  129. package/lib/detail-page/reference-image.ts +103 -0
  130. package/lib/detail-page/reference-upload.ts +181 -0
  131. package/lib/detail-page/replace-page.ts +35 -0
  132. package/lib/detail-page/replace-with-gif.ts +251 -0
  133. package/lib/detail-page/section-height.ts +131 -0
  134. package/lib/detail-page/section-reauthor-bus.ts +61 -0
  135. package/lib/detail-page/shape-to-image.ts +219 -0
  136. package/lib/detail-page/spec-group/clipboard.ts +35 -0
  137. package/lib/detail-page/spec-group/sync.ts +377 -0
  138. package/lib/detail-page/spec-group/text-metrics.ts +48 -0
  139. package/lib/detail-page/stock-photos.ts +88 -0
  140. package/lib/detail-page/svg-colors.ts +74 -0
  141. package/lib/detail-page/table/defaults.ts +344 -0
  142. package/lib/detail-page/table/edit.ts +312 -0
  143. package/lib/detail-page/table/harvest.ts +103 -0
  144. package/lib/detail-page/table/normalize.ts +125 -0
  145. package/lib/detail-page/table/paste.ts +49 -0
  146. package/lib/detail-page/table/preview.ts +66 -0
  147. package/lib/detail-page/table/render.ts +388 -0
  148. package/lib/detail-page/table/sync.ts +107 -0
  149. package/lib/detail-page/table/types.ts +102 -0
  150. package/lib/detail-page/text-gif-layout.ts +245 -0
  151. package/lib/detail-page/text-gif-payload.ts +67 -0
  152. package/lib/detail-page/text-presets.ts +230 -0
  153. package/lib/detail-page/use-fake-progress.ts +67 -0
  154. package/lib/detail-page/z-order.ts +82 -0
  155. package/lib/detail-page-canvas/bubble-path.ts +190 -0
  156. package/lib/detail-page-canvas/canvas-key.ts +22 -0
  157. package/lib/detail-page-canvas/clip-rect.ts +65 -0
  158. package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
  159. package/lib/detail-page-canvas/editor-fonts.ts +161 -0
  160. package/lib/detail-page-canvas/export/ai.ts +413 -0
  161. package/lib/detail-page-canvas/export/color.ts +35 -0
  162. package/lib/detail-page-canvas/export/document-model.ts +78 -0
  163. package/lib/detail-page-canvas/export/export-files.ts +290 -0
  164. package/lib/detail-page-canvas/export/font-subset.ts +264 -0
  165. package/lib/detail-page-canvas/export/gif-export.ts +310 -0
  166. package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
  167. package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
  168. package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
  169. package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
  170. package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
  171. package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
  172. package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
  173. package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
  174. package/lib/detail-page-canvas/export/psd.ts +354 -0
  175. package/lib/detail-page-canvas/export/raster.ts +255 -0
  176. package/lib/detail-page-canvas/export/svg.ts +479 -0
  177. package/lib/detail-page-canvas/export/text-layout.ts +142 -0
  178. package/lib/detail-page-canvas/font-catalog.ts +177 -0
  179. package/lib/detail-page-canvas/font-tags.ts +47 -0
  180. package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
  181. package/lib/detail-page-canvas/konva-fallback.ts +477 -0
  182. package/lib/detail-page-canvas/text-background-plan.ts +124 -0
  183. package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
  184. package/lib/utils.ts +10 -0
  185. package/package.json +68 -0
  186. package/types/commerce.ts +171 -0
  187. package/types/detail-page-api.ts +200 -0
  188. package/types/detail-page-canvas.ts +20 -0
  189. package/types/fontkit.d.ts +42 -0
  190. package/types/gifenc.d.ts +51 -0
@@ -0,0 +1,443 @@
1
+ import { parseColor } from "../color";
2
+ import { fmt, PdfBuilder, pdfString, type PdfRef } from "./writer";
3
+
4
+ /**
5
+ * The page-resource side of the PDF (.ai) exporter: fonts, gradients, alpha
6
+ * states and images are collected while the content stream is written, then
7
+ * emitted as one shared ``/Resources`` dictionary.
8
+ */
9
+
10
+ export type RgbColor = [number, number, number];
11
+
12
+ /**
13
+ * A colour PDF can actually paint with. PDF has no alpha in its colour
14
+ * operators — transparency lives in a separate graphics state — so the two are
15
+ * split here. A fully transparent colour returns null and paints nothing: our
16
+ * documents use ``rgba(0,0,0,0)`` for "no fill", and taking only its RGB would
17
+ * flood the shape with opaque black.
18
+ */
19
+ export type Paint = { color: RgbColor; alpha: number };
20
+
21
+ export function parsePaint(value: unknown): Paint | null {
22
+ const parsed = parseColor(value);
23
+ if (!parsed || parsed.a <= 0) return null;
24
+ return {
25
+ color: [parsed.r / 255, parsed.g / 255, parsed.b / 255],
26
+ alpha: parsed.a,
27
+ };
28
+ }
29
+
30
+ /** Colour operator operands: ``rg`` (fill) / ``RG`` (stroke). */
31
+ export const rgbOps = (color: RgbColor) => color.map(fmt).join(" ");
32
+
33
+ export type ShadingStop = { offset: number; color: RgbColor };
34
+
35
+ export type ShadingSpec =
36
+ | { kind: "axial"; x0: number; y0: number; x1: number; y1: number; stops: ShadingStop[] }
37
+ | { kind: "radial"; cx: number; cy: number; r: number; stops: ShadingStop[] };
38
+
39
+ export type PdfFontSpec = { family: string; weight: number; italic: boolean };
40
+
41
+ export type PdfEmbeddedGlyph = {
42
+ /** Glyph/CID in the encoded subset. */
43
+ id: number;
44
+ /** Advance width normalized to 1000 units for the PDF /W array. */
45
+ width: number;
46
+ /** Original Unicode scalar represented by this glyph. */
47
+ unicode: string;
48
+ };
49
+
50
+ export type PdfEmbeddedFont = {
51
+ spec: PdfFontSpec;
52
+ data: Uint8Array;
53
+ format: "truetype" | "cff";
54
+ postscriptName: string;
55
+ unitsPerEm: number;
56
+ ascent: number;
57
+ descent: number;
58
+ capHeight: number;
59
+ italicAngle: number;
60
+ bbox: [number, number, number, number];
61
+ /** Character to subset glyph mapping. */
62
+ glyphs: Array<PdfEmbeddedGlyph & { char: string }>;
63
+ };
64
+
65
+ /**
66
+ * A decoded bitmap ready to embed. ``jpeg`` goes in untouched (DCTDecode);
67
+ * ``rgb`` is raw 8-bit RGB the writer deflates. ``alpha`` becomes the soft
68
+ * mask, which is what keeps a transparent cut-out or a rounded corner from
69
+ * turning into a white box.
70
+ */
71
+ export type AiBitmap = {
72
+ width: number;
73
+ height: number;
74
+ format: "jpeg" | "rgb";
75
+ data: Uint8Array;
76
+ alpha?: Uint8Array;
77
+ };
78
+
79
+ const WEIGHT_NAMES: Array<[number, string]> = [
80
+ [150, "Thin"],
81
+ [250, "ExtraLight"],
82
+ [350, "Light"],
83
+ [450, "Regular"],
84
+ [550, "Medium"],
85
+ [650, "SemiBold"],
86
+ [750, "Bold"],
87
+ [850, "ExtraBold"],
88
+ [Infinity, "Black"],
89
+ ];
90
+
91
+ /**
92
+ * PostScript-style name Illustrator will look for, e.g. ``Pretendard-Bold``.
93
+ * The font is not embedded, so this name is the whole of what Illustrator has
94
+ * to find (or substitute) — same contract as the PSD exporter's layer fonts.
95
+ */
96
+ export function pdfFontName(spec: PdfFontSpec): string {
97
+ const family = (spec.family || "Pretendard").replace(/\s+/g, "");
98
+ const weight = WEIGHT_NAMES.find(([max]) => spec.weight < max)?.[1] ?? "Regular";
99
+ const italic = spec.italic ? "Italic" : "";
100
+ if (weight === "Regular" && italic) return `${family}-Italic`;
101
+ return `${family}-${weight}${italic}`;
102
+ }
103
+
104
+ export function pdfFontKey(spec: PdfFontSpec): string {
105
+ return `${spec.family}\u0000${spec.weight}\u0000${spec.italic ? "italic" : "normal"}`;
106
+ }
107
+
108
+ function safePdfName(value: string): string {
109
+ return value.replace(/[^A-Za-z0-9_.-]/g, "") || "EmbeddedFont";
110
+ }
111
+
112
+ function subsetFontName(resource: string, postscriptName: string): string {
113
+ let hash = 0;
114
+ for (const char of resource) hash = (hash * 33 + char.charCodeAt(0)) >>> 0;
115
+ const prefix = hash.toString(26).toUpperCase().replace(/[^A-Z]/g, "A").padEnd(6, "A").slice(0, 6);
116
+ return `${prefix}+${safePdfName(postscriptName)}`;
117
+ }
118
+
119
+ function normalizedMetric(value: number, unitsPerEm: number): number {
120
+ return Math.round((value / Math.max(1, unitsPerEm)) * 1000);
121
+ }
122
+
123
+ function toUnicodeCMap(fontName: string, glyphs: PdfEmbeddedFont["glyphs"]): Uint8Array {
124
+ const byId = new Map<number, string>();
125
+ for (const glyph of glyphs) {
126
+ if (!byId.has(glyph.id)) byId.set(glyph.id, glyph.unicode);
127
+ }
128
+ const entries = [...byId]
129
+ .filter(([id]) => id >= 0 && id <= 0xffff)
130
+ .sort(([a], [b]) => a - b);
131
+ const lines = [
132
+ "/CIDInit /ProcSet findresource begin",
133
+ "12 dict begin",
134
+ "begincmap",
135
+ "/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def",
136
+ `/CMapName /${safePdfName(fontName)}-UCS def`,
137
+ "/CMapType 2 def",
138
+ "1 begincodespacerange",
139
+ "<0000> <FFFF>",
140
+ "endcodespacerange",
141
+ ];
142
+ for (let at = 0; at < entries.length; at += 100) {
143
+ const chunk = entries.slice(at, at + 100);
144
+ lines.push(`${chunk.length} beginbfchar`);
145
+ for (const [id, unicode] of chunk) {
146
+ lines.push(
147
+ `<${id.toString(16).padStart(4, "0").toUpperCase()}> <${[...unicode]
148
+ .map((char) => {
149
+ const code = char.codePointAt(0) ?? 0;
150
+ if (code <= 0xffff) return code.toString(16).padStart(4, "0").toUpperCase();
151
+ const scalar = code - 0x10000;
152
+ const high = 0xd800 + (scalar >> 10);
153
+ const low = 0xdc00 + (scalar & 0x3ff);
154
+ return (
155
+ high.toString(16).padStart(4, "0") +
156
+ low.toString(16).padStart(4, "0")
157
+ ).toUpperCase();
158
+ })
159
+ .join("")}>`,
160
+ );
161
+ }
162
+ lines.push("endbfchar");
163
+ }
164
+ lines.push("endcmap", "CMapName currentdict /CMap defineresource pop", "end", "end");
165
+ return new TextEncoder().encode(lines.join("\n"));
166
+ }
167
+
168
+ /** Normalize stops to cover [0, 1]; PDF functions have no "extend" of their own. */
169
+ function normalizeStops(stops: ShadingStop[]): ShadingStop[] {
170
+ const sorted = [...stops]
171
+ .map((s) => ({ offset: Math.min(1, Math.max(0, s.offset)), color: s.color }))
172
+ .sort((a, b) => a.offset - b.offset);
173
+ if (!sorted.length) return [{ offset: 0, color: [0, 0, 0] }];
174
+ if (sorted[0].offset > 0) sorted.unshift({ offset: 0, color: sorted[0].color });
175
+ const last = sorted[sorted.length - 1];
176
+ if (last.offset < 1) sorted.push({ offset: 1, color: last.color });
177
+ return sorted;
178
+ }
179
+
180
+ const color3 = (c: RgbColor) => `[${fmt(c[0])} ${fmt(c[1])} ${fmt(c[2])}]`;
181
+
182
+ /** A stitching function over the stops: PDF's way of writing a colour ramp. */
183
+ function shadingFunction(builder: PdfBuilder, stops: ShadingStop[]): PdfRef {
184
+ const points = normalizeStops(stops);
185
+ if (points.length === 2) {
186
+ return builder.add(
187
+ `<< /FunctionType 2 /Domain [0 1] /C0 ${color3(points[0].color)} ` +
188
+ `/C1 ${color3(points[1].color)} /N 1 >>`,
189
+ );
190
+ }
191
+ const segments: PdfRef[] = [];
192
+ const bounds: number[] = [];
193
+ for (let i = 0; i + 1 < points.length; i++) {
194
+ segments.push(
195
+ builder.add(
196
+ `<< /FunctionType 2 /Domain [0 1] /C0 ${color3(points[i].color)} ` +
197
+ `/C1 ${color3(points[i + 1].color)} /N 1 >>`,
198
+ ),
199
+ );
200
+ if (i > 0) bounds.push(points[i].offset);
201
+ }
202
+ return builder.add(
203
+ `<< /FunctionType 3 /Domain [0 1] ` +
204
+ `/Functions [${segments.map((r) => `${r} 0 R`).join(" ")}] ` +
205
+ `/Bounds [${bounds.map(fmt).join(" ")}] ` +
206
+ `/Encode [${segments.map(() => "0 1").join(" ")}] >>`,
207
+ );
208
+ }
209
+
210
+ export class ResourcePool {
211
+ private fontRes = new Map<
212
+ string,
213
+ { spec: PdfFontSpec; embedded?: PdfEmbeddedFont }
214
+ >();
215
+ private embeddedFonts = new Map<string, PdfEmbeddedFont>();
216
+ private embeddedGlyphs = new Map<
217
+ string,
218
+ Map<string, PdfEmbeddedFont["glyphs"][number]>
219
+ >();
220
+ private shadingRes = new Map<string, ShadingSpec>();
221
+ private alphaRes = new Map<string, [number, number]>();
222
+ private imageRes = new Map<AiBitmap, string>();
223
+
224
+ constructor(embeddedFonts: PdfEmbeddedFont[] = []) {
225
+ for (const font of embeddedFonts) {
226
+ const key = pdfFontKey(font.spec);
227
+ this.embeddedFonts.set(key, font);
228
+ this.embeddedGlyphs.set(
229
+ key,
230
+ new Map(font.glyphs.map((glyph) => [glyph.char, glyph])),
231
+ );
232
+ }
233
+ }
234
+
235
+ private embeddedFontFor(spec: PdfFontSpec): PdfEmbeddedFont | undefined {
236
+ const exact = this.embeddedFonts.get(pdfFontKey(spec));
237
+ if (exact) return exact;
238
+ const sameFamily = [...this.embeddedFonts.values()].filter(
239
+ (font) => font.spec.family === spec.family,
240
+ );
241
+ const sameFace = sameFamily.filter(
242
+ (font) =>
243
+ font.spec.italic === spec.italic,
244
+ );
245
+ const candidates = sameFace.length ? sameFace : sameFamily;
246
+ return candidates.reduce<PdfEmbeddedFont | undefined>(
247
+ (best, font) =>
248
+ !best ||
249
+ Math.abs(font.spec.weight - spec.weight) <
250
+ Math.abs(best.spec.weight - spec.weight)
251
+ ? font
252
+ : best,
253
+ undefined,
254
+ );
255
+ }
256
+
257
+ font(spec: PdfFontSpec): string {
258
+ const embedded = this.embeddedFontFor(spec);
259
+ const name = embedded?.postscriptName || pdfFontName(spec);
260
+ const res = `F${name}`.replace(/[^A-Za-z0-9]/g, "");
261
+ if (!this.fontRes.has(res)) this.fontRes.set(res, { spec, embedded });
262
+ return res;
263
+ }
264
+
265
+ textGlyph(resource: string, char: string): { hex: string; width: number } | null {
266
+ const record = this.fontRes.get(resource);
267
+ if (!record?.embedded) return null;
268
+ const glyph = this.embeddedGlyphs
269
+ .get(pdfFontKey(record.embedded.spec))
270
+ ?.get(char);
271
+ if (!glyph) return { hex: "0000", width: 1000 };
272
+ return {
273
+ hex: glyph.id.toString(16).padStart(4, "0").toUpperCase(),
274
+ width: glyph.width,
275
+ };
276
+ }
277
+
278
+ syntheticItalic(resource: string): boolean {
279
+ const record = this.fontRes.get(resource);
280
+ return Boolean(
281
+ record?.embedded && record.spec.italic && !record.embedded.spec.italic,
282
+ );
283
+ }
284
+
285
+ shading(spec: ShadingSpec): string {
286
+ const key = JSON.stringify(spec);
287
+ for (const [res, existing] of this.shadingRes) {
288
+ if (JSON.stringify(existing) === key) return res;
289
+ }
290
+ const res = `Sh${this.shadingRes.size + 1}`;
291
+ this.shadingRes.set(res, spec);
292
+ return res;
293
+ }
294
+
295
+ /** Constant fill/stroke alpha as an ExtGState. */
296
+ alpha(fill: number, stroke: number = fill): string {
297
+ const clamp = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 100) / 100;
298
+ const [ca, CA] = [clamp(fill), clamp(stroke)];
299
+ const res = `GS${String(ca).replace(".", "")}x${String(CA).replace(".", "")}`;
300
+ if (!this.alphaRes.has(res)) this.alphaRes.set(res, [ca, CA]);
301
+ return res;
302
+ }
303
+
304
+ image(bitmap: AiBitmap): string {
305
+ const existing = this.imageRes.get(bitmap);
306
+ if (existing) return existing;
307
+ const res = `Im${this.imageRes.size + 1}`;
308
+ this.imageRes.set(bitmap, res);
309
+ return res;
310
+ }
311
+
312
+ /**
313
+ * Write every collected resource and return the ``/Resources`` dict body.
314
+ * ``deflate`` is optional: without it, raw bitmaps go in uncompressed (valid
315
+ * PDF, just larger) so the builder still works outside a browser.
316
+ */
317
+ async write(
318
+ builder: PdfBuilder,
319
+ deflate?: (data: Uint8Array) => Promise<Uint8Array>,
320
+ ): Promise<string> {
321
+ const fonts: string[] = [];
322
+ for (const [res, { spec, embedded }] of this.fontRes) {
323
+ if (embedded) {
324
+ const name = subsetFontName(res, embedded.postscriptName);
325
+ const fontFile =
326
+ embedded.format === "truetype"
327
+ ? builder.addStream(`/Length1 ${embedded.data.length}`, embedded.data)
328
+ : builder.addStream("/Subtype /CIDFontType0C", embedded.data);
329
+ const units = Math.max(1, embedded.unitsPerEm);
330
+ const bbox = embedded.bbox.map((value) => normalizedMetric(value, units));
331
+ const descriptor = builder.add(
332
+ `<< /Type /FontDescriptor /FontName /${name} ` +
333
+ `/Flags ${32 + (spec.italic ? 64 : 0)} ` +
334
+ `/FontBBox [${bbox.join(" ")}] ` +
335
+ `/ItalicAngle ${fmt(embedded.italicAngle)} ` +
336
+ `/Ascent ${normalizedMetric(embedded.ascent, units)} ` +
337
+ `/Descent ${normalizedMetric(embedded.descent, units)} ` +
338
+ `/CapHeight ${normalizedMetric(embedded.capHeight, units)} ` +
339
+ `/StemV ${spec.weight >= 600 ? 120 : 80} ` +
340
+ `/${embedded.format === "truetype" ? "FontFile2" : "FontFile3"} ${fontFile} 0 R >>`,
341
+ );
342
+ const widthsById = new Map<number, number>();
343
+ for (const glyph of embedded.glyphs) {
344
+ if (!widthsById.has(glyph.id)) widthsById.set(glyph.id, glyph.width);
345
+ }
346
+ const widths = [...widthsById]
347
+ .sort(([a], [b]) => a - b)
348
+ .map(([id, width]) => `${id} [${fmt(width)}]`)
349
+ .join(" ");
350
+ const descendant = builder.add(
351
+ `<< /Type /Font /Subtype /${
352
+ embedded.format === "truetype" ? "CIDFontType2" : "CIDFontType0"
353
+ } /BaseFont /${name} ` +
354
+ `/CIDSystemInfo << /Registry ${pdfString("Adobe")} /Ordering ${pdfString("Identity")} ` +
355
+ `/Supplement 0 >> /FontDescriptor ${descriptor} 0 R /DW 1000 ` +
356
+ `${widths ? `/W [${widths}] ` : ""}` +
357
+ `${embedded.format === "truetype" ? "/CIDToGIDMap /Identity " : ""}>>`,
358
+ );
359
+ const toUnicode = builder.addStream("", toUnicodeCMap(name, embedded.glyphs));
360
+ const font = builder.add(
361
+ `<< /Type /Font /Subtype /Type0 /BaseFont /${name} /Encoding /Identity-H ` +
362
+ `/DescendantFonts [${descendant} 0 R] /ToUnicode ${toUnicode} 0 R >>`,
363
+ );
364
+ fonts.push(`/${res} ${font} 0 R`);
365
+ continue;
366
+ }
367
+ const name = pdfFontName(spec);
368
+ // Non-embedded CID font. /UniKS-UCS2-H is a CMap every PDF reader ships
369
+ // (Adobe-Korea1): it maps the UTF-16BE code points we write straight to
370
+ // the glyphs of whichever Korean font the reader substitutes — which is
371
+ // how the text survives without the font binary. Widths come from the
372
+ // TJ adjustments in text-ops.ts, not from /W.
373
+ const descriptor =
374
+ `<< /Type /FontDescriptor /FontName /${name} /Flags 4 ` +
375
+ `/FontBBox [-100 -250 1100 900] /ItalicAngle ${spec.italic ? -12 : 0} ` +
376
+ `/Ascent 880 /Descent -120 /CapHeight 700 /StemV ${spec.weight >= 600 ? 120 : 80} >>`;
377
+ const descendant = builder.add(
378
+ `<< /Type /Font /Subtype /CIDFontType0 /BaseFont /${name} ` +
379
+ `/CIDSystemInfo << /Registry ${pdfString("Adobe")} /Ordering ${pdfString("Korea1")} ` +
380
+ `/Supplement 2 >> /FontDescriptor ${descriptor} /DW 1000 >>`,
381
+ );
382
+ const font = builder.add(
383
+ `<< /Type /Font /Subtype /Type0 /BaseFont /${name} /Encoding /UniKS-UCS2-H ` +
384
+ `/DescendantFonts [${descendant} 0 R] >>`,
385
+ );
386
+ fonts.push(`/${res} ${font} 0 R`);
387
+ }
388
+
389
+ const shadings: string[] = [];
390
+ for (const [res, spec] of this.shadingRes) {
391
+ const fn = shadingFunction(builder, spec.stops);
392
+ const coords =
393
+ spec.kind === "axial"
394
+ ? `/ShadingType 2 /Coords [${fmt(spec.x0)} ${fmt(spec.y0)} ${fmt(spec.x1)} ${fmt(spec.y1)}]`
395
+ : `/ShadingType 3 /Coords [${fmt(spec.cx)} ${fmt(spec.cy)} 0 ${fmt(spec.cx)} ${fmt(spec.cy)} ${fmt(spec.r)}]`;
396
+ const ref = builder.add(
397
+ `<< ${coords} /ColorSpace /DeviceRGB /Function ${fn} 0 R /Extend [true true] >>`,
398
+ );
399
+ shadings.push(`/${res} ${ref} 0 R`);
400
+ }
401
+
402
+ const gstates: string[] = [];
403
+ for (const [res, [ca, CA]] of this.alphaRes) {
404
+ const ref = builder.add(`<< /Type /ExtGState /ca ${fmt(ca)} /CA ${fmt(CA)} >>`);
405
+ gstates.push(`/${res} ${ref} 0 R`);
406
+ }
407
+
408
+ const images: string[] = [];
409
+ for (const [bitmap, res] of this.imageRes) {
410
+ let smask = "";
411
+ if (bitmap.alpha) {
412
+ const maskRef = builder.alloc();
413
+ const masked = deflate ? await deflate(bitmap.alpha) : bitmap.alpha;
414
+ builder.addStream(
415
+ `/Type /XObject /Subtype /Image /Width ${bitmap.width} /Height ${bitmap.height} ` +
416
+ `/ColorSpace /DeviceGray /BitsPerComponent 8` +
417
+ (deflate ? " /Filter /FlateDecode" : ""),
418
+ masked,
419
+ maskRef,
420
+ );
421
+ smask = ` /SMask ${maskRef} 0 R`;
422
+ }
423
+ const isJpeg = bitmap.format === "jpeg";
424
+ const data = isJpeg || !deflate ? bitmap.data : await deflate(bitmap.data);
425
+ const filter = isJpeg ? " /Filter /DCTDecode" : deflate ? " /Filter /FlateDecode" : "";
426
+ const ref = builder.addStream(
427
+ `/Type /XObject /Subtype /Image /Width ${bitmap.width} /Height ${bitmap.height} ` +
428
+ `/ColorSpace /DeviceRGB /BitsPerComponent 8${filter}${smask}`,
429
+ data,
430
+ );
431
+ images.push(`/${res} ${ref} 0 R`);
432
+ }
433
+
434
+ const dict = [
435
+ "/ProcSet [/PDF /Text /ImageC]",
436
+ fonts.length ? `/Font << ${fonts.join(" ")} >>` : "",
437
+ shadings.length ? `/Shading << ${shadings.join(" ")} >>` : "",
438
+ gstates.length ? `/ExtGState << ${gstates.join(" ")} >>` : "",
439
+ images.length ? `/XObject << ${images.join(" ")} >>` : "",
440
+ ].filter(Boolean);
441
+ return `<< ${dict.join(" ")} >>`;
442
+ }
443
+ }
@@ -0,0 +1,81 @@
1
+ import { fmt, utf16Hex } from "./writer";
2
+
3
+ /**
4
+ * PDF text-showing operators for the Illustrator (.ai) exporter.
5
+ *
6
+ * Catalog fonts are embedded as subsets; legacy document fonts keep the
7
+ * non-embedded Korean-CMap fallback. Both paths use explicit ``TJ`` corrections
8
+ * so browser kerning, letter spacing and the stock editor's horizontal condensation stay
9
+ * aligned with the editor.
10
+ *
11
+ * So each glyph carries its own correction. ``TJ`` takes a mixed array of
12
+ * strings and numbers, where a number nudges the next glyph left by
13
+ * thousandths of an em. Feeding it the advance we measured on canvas in the
14
+ * real font makes the line land exactly where the editor draws it — and it
15
+ * keeps working when Illustrator substitutes the font, because the positions
16
+ * come from the file rather than from whatever font the machine has.
17
+ */
18
+
19
+ export type ShowTextOptions = {
20
+ /** Font resource name in the page's /Font dict, e.g. "F1". */
21
+ fontRes: string;
22
+ fontSize: number;
23
+ /** Left edge of the text (alignment is resolved by the caller). */
24
+ x: number;
25
+ baseline: number;
26
+ text: string;
27
+ /** Advance of a string in the real font, in px at ``fontSize``. */
28
+ measure: (text: string) => number;
29
+ /** Horizontal condensation (Tz); matches the SVG exporter's scaleX. */
30
+ scaleX?: number;
31
+ /** Embedded fonts encode subset glyph IDs and supply their nominal widths. */
32
+ glyph?: (char: string) => { hex: string; width: number } | null;
33
+ /** Synthetic italic shear when only a normal source face exists. */
34
+ skewX?: number;
35
+ };
36
+
37
+ /**
38
+ * Advance of each character, in px, taken from prefix measurements so that
39
+ * kerning between neighbours stays inside the running total (measuring each
40
+ * character alone would drop it).
41
+ */
42
+ export function charAdvances(text: string, measure: (t: string) => number): number[] {
43
+ const chars = Array.from(text);
44
+ const advances: number[] = [];
45
+ let previous = 0;
46
+ for (let i = 0; i < chars.length; i++) {
47
+ const width = measure(chars.slice(0, i + 1).join(""));
48
+ advances.push(width - previous);
49
+ previous = width;
50
+ }
51
+ return advances;
52
+ }
53
+
54
+ export function showTextOps(opts: ShowTextOptions): string[] {
55
+ const { fontRes, fontSize, x, baseline, text } = opts;
56
+ if (!text || fontSize <= 0) return [];
57
+ const scaleX = opts.scaleX && opts.scaleX > 0 ? opts.scaleX : 1;
58
+ const chars = Array.from(text);
59
+ const advances = charAdvances(text, opts.measure);
60
+
61
+ const parts: string[] = [];
62
+ for (let i = 0; i < chars.length; i++) {
63
+ const encoded = opts.glyph?.(chars[i]);
64
+ const nominalWidth = encoded?.width ?? 1000;
65
+ parts.push(`<${encoded?.hex ?? utf16Hex(chars[i])}>`);
66
+ // Subtract the browser-measured advance from the embedded (or fallback)
67
+ // nominal width. PDF TJ numbers move the next glyph left in 1/1000 em.
68
+ const adjust = nominalWidth - (advances[i] / fontSize) * 1000;
69
+ if (Math.abs(adjust) > 0.5) parts.push(fmt(adjust));
70
+ }
71
+
72
+ return [
73
+ "BT",
74
+ `/${fontRes} ${fmt(fontSize)} Tf`,
75
+ ...(scaleX !== 1 ? [`${fmt(scaleX * 100)} Tz`] : []),
76
+ // The page CTM is flipped to y-down; the -1 flips text back upright.
77
+ `1 0 ${fmt(opts.skewX ?? 0)} -1 ${fmt(x)} ${fmt(baseline)} Tm`,
78
+ `[${parts.join(" ")}] TJ`,
79
+ "ET",
80
+ ];
81
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Minimal PDF 1.7 writer — just enough for the Illustrator (.ai) exporter.
3
+ *
4
+ * An ``.ai`` file has been a PDF underneath since Illustrator 9 ("PDF
5
+ * compatibility"): Illustrator opens a plain PDF renamed to ``.ai`` and turns
6
+ * its content stream back into editable paths and text. So the exporter's job
7
+ * is to emit a well-formed PDF, and this module is the byte layer for it —
8
+ * object table, streams, cross-reference table, trailer.
9
+ *
10
+ * Kept dependency-free on purpose: jsPDF (the obvious alternative) can only
11
+ * embed TrueType fonts it has the binary for, and our fonts ship as ~2,000
12
+ * unicode-range woff2 subsets. See ``ai.ts`` for how text works without them.
13
+ */
14
+
15
+ const ENCODER = new TextEncoder();
16
+
17
+ export type PdfRef = number;
18
+
19
+ /** Compact fixed-point formatting; PDF content streams get long fast. */
20
+ export function fmt(n: number): string {
21
+ if (!Number.isFinite(n)) return "0";
22
+ const rounded = Math.round(n * 1000) / 1000;
23
+ return Object.is(rounded, -0) ? "0" : String(rounded);
24
+ }
25
+
26
+ /** Escape a PDF literal string: ``(Adobe)``. */
27
+ export function pdfString(value: string): string {
28
+ return `(${value.replace(/[\\()]/g, (c) => `\\${c}`)})`;
29
+ }
30
+
31
+ /** UTF-16BE hex string — the text encoding our CID fonts read (see ai.ts). */
32
+ export function utf16Hex(text: string): string {
33
+ let out = "";
34
+ for (let i = 0; i < text.length; i++) {
35
+ out += text.charCodeAt(i).toString(16).padStart(4, "0").toUpperCase();
36
+ }
37
+ return out;
38
+ }
39
+
40
+ function concat(chunks: Uint8Array[]): Uint8Array {
41
+ const total = chunks.reduce((acc, c) => acc + c.length, 0);
42
+ const out = new Uint8Array(total);
43
+ let at = 0;
44
+ for (const chunk of chunks) {
45
+ out.set(chunk, at);
46
+ at += chunk.length;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ export class PdfBuilder {
52
+ /** Index = object number; index 0 is the free head, so it stays null. */
53
+ private objects: (Uint8Array | null)[] = [null];
54
+
55
+ /** Reserve an object number before its body exists (forward references). */
56
+ alloc(): PdfRef {
57
+ this.objects.push(null);
58
+ return this.objects.length - 1;
59
+ }
60
+
61
+ set(ref: PdfRef, body: string | Uint8Array): PdfRef {
62
+ this.objects[ref] = typeof body === "string" ? ENCODER.encode(body) : body;
63
+ return ref;
64
+ }
65
+
66
+ add(body: string | Uint8Array): PdfRef {
67
+ return this.set(this.alloc(), body);
68
+ }
69
+
70
+ /**
71
+ * A stream object. ``extra`` carries the dict entries that describe the data
72
+ * (``/Filter``, ``/Subtype``, image geometry…); ``/Length`` is added here.
73
+ */
74
+ addStream(extra: string, data: Uint8Array, ref: PdfRef = this.alloc()): PdfRef {
75
+ const head = ENCODER.encode(`<< ${extra} /Length ${data.length} >>\nstream\n`);
76
+ const tail = ENCODER.encode("\nendstream");
77
+ return this.set(ref, concat([head, data, tail]));
78
+ }
79
+
80
+ /** Serialize the document; ``root`` must be the /Catalog object. */
81
+ build(root: PdfRef): Uint8Array {
82
+ // Binary comment on line 2 marks the file as binary for transfer tools.
83
+ const chunks: Uint8Array[] = [ENCODER.encode("%PDF-1.7\n"), new Uint8Array([37, 226, 227, 207, 211, 10])];
84
+ let offset = chunks.reduce((acc, c) => acc + c.length, 0);
85
+ const offsets: number[] = new Array(this.objects.length).fill(0);
86
+
87
+ for (let n = 1; n < this.objects.length; n++) {
88
+ const body = this.objects[n];
89
+ if (!body) continue; // allocated but never filled — emitted as a free slot
90
+ offsets[n] = offset;
91
+ const head = ENCODER.encode(`${n} 0 obj\n`);
92
+ const tail = ENCODER.encode("\nendobj\n");
93
+ chunks.push(head, body, tail);
94
+ offset += head.length + body.length + tail.length;
95
+ }
96
+
97
+ const xrefAt = offset;
98
+ const size = this.objects.length;
99
+ let xref = `xref\n0 ${size}\n0000000000 65535 f \n`;
100
+ for (let n = 1; n < size; n++) {
101
+ xref += offsets[n]
102
+ ? `${String(offsets[n]).padStart(10, "0")} 00000 n \n`
103
+ : "0000000000 65535 f \n";
104
+ }
105
+ xref += `trailer\n<< /Size ${size} /Root ${root} 0 R >>\nstartxref\n${xrefAt}\n%%EOF\n`;
106
+ chunks.push(ENCODER.encode(xref));
107
+ return concat(chunks);
108
+ }
109
+ }