quicklook-pptx-renderer 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 (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +266 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +175 -0
  5. package/dist/diff/compare.d.ts +17 -0
  6. package/dist/diff/compare.js +71 -0
  7. package/dist/index.d.ts +29 -0
  8. package/dist/index.js +72 -0
  9. package/dist/lint.d.ts +27 -0
  10. package/dist/lint.js +328 -0
  11. package/dist/mapper/bleed-map.d.ts +6 -0
  12. package/dist/mapper/bleed-map.js +1 -0
  13. package/dist/mapper/constants.d.ts +2 -0
  14. package/dist/mapper/constants.js +4 -0
  15. package/dist/mapper/drawable-mapper.d.ts +16 -0
  16. package/dist/mapper/drawable-mapper.js +1464 -0
  17. package/dist/mapper/html-generator.d.ts +13 -0
  18. package/dist/mapper/html-generator.js +539 -0
  19. package/dist/mapper/image-mapper.d.ts +14 -0
  20. package/dist/mapper/image-mapper.js +70 -0
  21. package/dist/mapper/nano-malloc.d.ts +130 -0
  22. package/dist/mapper/nano-malloc.js +197 -0
  23. package/dist/mapper/ql-bleed.d.ts +35 -0
  24. package/dist/mapper/ql-bleed.js +254 -0
  25. package/dist/mapper/shape-mapper.d.ts +41 -0
  26. package/dist/mapper/shape-mapper.js +2384 -0
  27. package/dist/mapper/slide-mapper.d.ts +4 -0
  28. package/dist/mapper/slide-mapper.js +112 -0
  29. package/dist/mapper/style-builder.d.ts +12 -0
  30. package/dist/mapper/style-builder.js +30 -0
  31. package/dist/mapper/text-mapper.d.ts +14 -0
  32. package/dist/mapper/text-mapper.js +302 -0
  33. package/dist/model/enums.d.ts +25 -0
  34. package/dist/model/enums.js +2 -0
  35. package/dist/model/types.d.ts +482 -0
  36. package/dist/model/types.js +7 -0
  37. package/dist/package/content-types.d.ts +1 -0
  38. package/dist/package/content-types.js +4 -0
  39. package/dist/package/package.d.ts +10 -0
  40. package/dist/package/package.js +52 -0
  41. package/dist/package/relationships.d.ts +6 -0
  42. package/dist/package/relationships.js +25 -0
  43. package/dist/package/zip.d.ts +6 -0
  44. package/dist/package/zip.js +17 -0
  45. package/dist/reader/color.d.ts +3 -0
  46. package/dist/reader/color.js +79 -0
  47. package/dist/reader/drawing.d.ts +17 -0
  48. package/dist/reader/drawing.js +403 -0
  49. package/dist/reader/effects.d.ts +2 -0
  50. package/dist/reader/effects.js +83 -0
  51. package/dist/reader/fill.d.ts +2 -0
  52. package/dist/reader/fill.js +94 -0
  53. package/dist/reader/presentation.d.ts +5 -0
  54. package/dist/reader/presentation.js +127 -0
  55. package/dist/reader/slide-layout.d.ts +2 -0
  56. package/dist/reader/slide-layout.js +28 -0
  57. package/dist/reader/slide-master.d.ts +4 -0
  58. package/dist/reader/slide-master.js +49 -0
  59. package/dist/reader/slide.d.ts +2 -0
  60. package/dist/reader/slide.js +26 -0
  61. package/dist/reader/text-list-style.d.ts +2 -0
  62. package/dist/reader/text-list-style.js +9 -0
  63. package/dist/reader/text.d.ts +5 -0
  64. package/dist/reader/text.js +295 -0
  65. package/dist/reader/theme.d.ts +2 -0
  66. package/dist/reader/theme.js +109 -0
  67. package/dist/reader/transform.d.ts +2 -0
  68. package/dist/reader/transform.js +21 -0
  69. package/dist/render/image-renderer.d.ts +3 -0
  70. package/dist/render/image-renderer.js +33 -0
  71. package/dist/render/renderer.d.ts +9 -0
  72. package/dist/render/renderer.js +178 -0
  73. package/dist/render/shape-renderer.d.ts +3 -0
  74. package/dist/render/shape-renderer.js +175 -0
  75. package/dist/render/text-renderer.d.ts +3 -0
  76. package/dist/render/text-renderer.js +152 -0
  77. package/dist/resolve/color-resolver.d.ts +18 -0
  78. package/dist/resolve/color-resolver.js +321 -0
  79. package/dist/resolve/font-map.d.ts +2 -0
  80. package/dist/resolve/font-map.js +66 -0
  81. package/dist/resolve/inheritance.d.ts +5 -0
  82. package/dist/resolve/inheritance.js +106 -0
  83. package/package.json +74 -0
@@ -0,0 +1,1464 @@
1
+ import { mapShape, mapConnector, getShapeDrawCmd, buildPdf, PDF_PADDING, SUPPORTED_GEOMETRIES, fillToColor as shapeFillToColor, shapeTextBox, } from "./shape-mapper.js";
2
+ import { mapPicture, nextAttachmentIndex } from "./image-mapper.js";
3
+ import { mapTextBody, mapTextParagraphs } from "./text-mapper.js";
4
+ import { resolveColor } from "../resolve/color-resolver.js";
5
+ import { emuToPx } from "./constants.js";
6
+ export function clearDrawableCache() {
7
+ // Reserved for future use — currently a no-op since bleed is handled
8
+ // externally via QL extraction (ql-bleed.ts) rather than inline caching.
9
+ }
10
+ export function mapDrawable(drawable, styles, attachments, ctx, drawableIndex) {
11
+ if (drawable.hidden)
12
+ return "";
13
+ switch (drawable.drawableType) {
14
+ case "sp":
15
+ return mapShape(drawable, styles, attachments, ctx);
16
+ case "pic":
17
+ return mapPicture(drawable, styles, attachments, ctx);
18
+ case "grpSp":
19
+ return mapGroup(drawable, styles, attachments, ctx);
20
+ case "cxnSp":
21
+ return mapConnector(drawable, attachments, ctx);
22
+ case "graphicFrame":
23
+ return mapGraphicFrame(drawable, styles, ctx, attachments);
24
+ }
25
+ return "";
26
+ }
27
+ function mapGroup(group, styles, attachments, ctx) {
28
+ // OfficeImport renders entire groups as a single PDF image.
29
+ // PMDrawableMapper.mapOfficeArtGroupAt: creates CMDrawingContext with group bounds,
30
+ // pushes coordinate transform, renders all children into same context, then copyPDF.
31
+ if (!group.bounds)
32
+ return "";
33
+ // Transform children to slide-global coordinates
34
+ const transformed = getTransformedChildren(group);
35
+ // Check if all children are PDF-renderable (shapes/connectors).
36
+ // Pictures need XObject embedding which we don't support in handwritten PDF yet.
37
+ if (!allChildrenPdfRenderable(transformed)) {
38
+ // Fall back: render children individually (old expand behavior)
39
+ return transformed.map((c) => mapDrawable(c, styles, attachments, ctx)).join("");
40
+ }
41
+ // Compute tight bounding box from actual children (QL uses content AABB, not group bounds)
42
+ const aabb = computeChildrenAABB(transformed);
43
+ if (!aabb)
44
+ return "";
45
+ const gx = aabb.x;
46
+ const gy = aabb.y;
47
+ const gw = aabb.cx;
48
+ const gh = aabb.cy;
49
+ if (gw <= 0 || gh <= 0)
50
+ return "";
51
+ // QL's CMDrawingContext tracks stroke bounding boxes via CoreGraphics.
52
+ // When 2+ shapes are drawn with B (fill+stroke), the hairline stroke (0.5pt)
53
+ // extends beyond the fill bounds. The cumulative stroke tracking shifts the
54
+ // _finalFrame origin by floor(-0.5) = -1 relative to the content AABB.
55
+ // With a single shape, CG sets bounds directly without stroke tracking.
56
+ const leafCount = countLeafChildren(transformed);
57
+ const strokeOffset = leafCount >= 2 ? 1 : 0;
58
+ // Build combined PDF stream with all children
59
+ const pad = PDF_PADDING;
60
+ const totalW = gw + pad * 2;
61
+ const totalH = gh + pad * 2;
62
+ const streamParts = [];
63
+ collectChildPdfStreams(transformed, gx, gy, totalH, ctx, streamParts);
64
+ if (streamParts.length === 0)
65
+ return "";
66
+ const pdf = buildPdf(totalW, totalH, streamParts.join("\n"));
67
+ const imgX = gx - pad - strokeOffset;
68
+ const imgY = gy - pad - strokeOffset;
69
+ const idx = nextAttachmentIndex();
70
+ const name = `Attachment${idx}.pdf`;
71
+ attachments.set(name, pdf);
72
+ let html = `<img src="${name}" style="position:absolute; top:${imgY}; left:${imgX}; width:${totalW}; height:${totalH};">`;
73
+ // QL overlays text divs for child shapes that have text (PMShapeTextMapper)
74
+ // The stroke offset applies to text coordinates matching the AABB minimum
75
+ html += collectGroupTextOverlays(transformed, styles, ctx, strokeOffset > 0 ? aabb : undefined);
76
+ return html;
77
+ }
78
+ /** Count leaf (non-group) children recursively. */
79
+ function countLeafChildren(children) {
80
+ let count = 0;
81
+ for (const c of children) {
82
+ if (c.hidden)
83
+ continue;
84
+ if (c.drawableType === "grpSp") {
85
+ count += countLeafChildren(getTransformedChildren(c));
86
+ }
87
+ else {
88
+ count++;
89
+ }
90
+ }
91
+ return count;
92
+ }
93
+ /** Emit text overlay divs for group children that have text (matching QL's behavior).
94
+ * When strokeAabb is provided, text coordinates matching the AABB minimum get -1 offset
95
+ * (replicating QL's CoreGraphics stroke tracking behavior for multi-child groups). */
96
+ function collectGroupTextOverlays(children, styles, ctx, strokeAabb) {
97
+ let html = "";
98
+ for (const child of children) {
99
+ if (child.hidden)
100
+ continue;
101
+ if (child.drawableType === "sp") {
102
+ const shape = child;
103
+ const hasText = shape.textBody?.paragraphs.some((p) => p.runs.some((r) => r.type !== "br" && "text" in r && String(r.text ?? "").trim() !== ""));
104
+ if (hasText && shape.textBody && shape.bounds) {
105
+ const geom = shape.geometry?.preset ?? "rect";
106
+ const tb = shapeTextBox(shape.bounds, geom, shape.geometry?.adjustValues);
107
+ const textCtx = {
108
+ colorMap: ctx.colorMap, colorScheme: ctx.colorScheme,
109
+ fontScheme: ctx.fontScheme, slide: ctx.slide,
110
+ fontRefColor: shape.shapeStyle?.fontRef?.color,
111
+ };
112
+ const textHtml = mapTextBody(shape.textBody, tb, styles, textCtx);
113
+ let tx = emuToPx(tb.x), ty = emuToPx(tb.y);
114
+ const tw = emuToPx(tb.cx), th = emuToPx(tb.cy);
115
+ // Apply stroke offset: coordinates at the AABB minimum shift by -1
116
+ if (strokeAabb) {
117
+ if (tx === strokeAabb.x)
118
+ tx -= 1;
119
+ if (ty === strokeAabb.y)
120
+ ty -= 1;
121
+ }
122
+ html += `<div style="position:absolute; top:${ty}; left:${tx}; width:${tw}; height:${th};">${textHtml}</div>`;
123
+ }
124
+ }
125
+ else if (child.drawableType === "grpSp") {
126
+ const sub = getTransformedChildren(child);
127
+ html += collectGroupTextOverlays(sub, styles, ctx, strokeAabb);
128
+ }
129
+ }
130
+ return html;
131
+ }
132
+ /** Compute tight AABB in CSS pixels from all leaf children (recursing into nested groups). */
133
+ function computeChildrenAABB(children) {
134
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
135
+ function visit(drawables) {
136
+ for (const d of drawables) {
137
+ if (d.hidden)
138
+ continue;
139
+ if (d.drawableType === "grpSp") {
140
+ const sub = getTransformedChildren(d);
141
+ visit(sub);
142
+ continue;
143
+ }
144
+ const b = d.bounds;
145
+ if (!b)
146
+ continue;
147
+ const x = emuToPx(b.x);
148
+ const y = emuToPx(b.y);
149
+ const r = x + emuToPx(b.cx);
150
+ const bot = y + emuToPx(b.cy);
151
+ if (x < minX)
152
+ minX = x;
153
+ if (y < minY)
154
+ minY = y;
155
+ if (r > maxX)
156
+ maxX = r;
157
+ if (bot > maxY)
158
+ maxY = bot;
159
+ }
160
+ }
161
+ visit(children);
162
+ if (minX === Infinity)
163
+ return null;
164
+ return { x: minX, y: minY, cx: maxX - minX, cy: maxY - minY };
165
+ }
166
+ /** Transform group children to slide-global coordinates. */
167
+ function getTransformedChildren(group) {
168
+ if (group.childBounds && group.childBounds.cx && group.childBounds.cy && group.bounds) {
169
+ const gb = group.bounds;
170
+ const cb = group.childBounds;
171
+ const scaleX = gb.cx / cb.cx;
172
+ const scaleY = gb.cy / cb.cy;
173
+ return group.children.map((c) => transformChildBounds(c, gb, cb, scaleX, scaleY));
174
+ }
175
+ return group.children;
176
+ }
177
+ /** Check if all children (recursively) are shapes or connectors (PDF-renderable). */
178
+ function allChildrenPdfRenderable(children) {
179
+ for (const c of children) {
180
+ if (c.drawableType === "sp" || c.drawableType === "cxnSp")
181
+ continue;
182
+ if (c.drawableType === "grpSp") {
183
+ const sub = getTransformedChildren(c);
184
+ if (!allChildrenPdfRenderable(sub))
185
+ return false;
186
+ continue;
187
+ }
188
+ return false; // pic, graphicFrame — can't embed in handwritten PDF
189
+ }
190
+ return true;
191
+ }
192
+ /** Recursively collect PDF drawing commands for all children in a group. */
193
+ function collectChildPdfStreams(children, groupX, groupY, pageTotalH, ctx, out) {
194
+ for (const child of children) {
195
+ if (child.hidden)
196
+ continue;
197
+ if (child.drawableType === "sp") {
198
+ const shape = child;
199
+ const b = shape.bounds;
200
+ if (!b)
201
+ continue;
202
+ const geom = shape.geometry?.preset ?? "rect";
203
+ if (!SUPPORTED_GEOMETRIES.has(geom))
204
+ continue;
205
+ const w = emuToPx(b.cx);
206
+ const h = emuToPx(b.cy);
207
+ if (w <= 0 || h <= 0)
208
+ continue;
209
+ const localX = emuToPx(b.x) - groupX + PDF_PADDING;
210
+ const localY = emuToPx(b.y) - groupY + PDF_PADDING;
211
+ // Draw commands in local (w x h) space (pad=0, totalH=h)
212
+ const drawCmd = getShapeDrawCmd(0, w, h, h, geom, shape.geometry?.adjustValues);
213
+ const color = shapeFillToColor(shape.fill, ctx) ?? "#000000";
214
+ const colorCmd = hexToRgCmd(color);
215
+ // PDF translate: move to child position in group page (Y-flipped)
216
+ const pdfX = localX;
217
+ const pdfY = pageTotalH - localY - h;
218
+ out.push(`q\n1 0 0 1 ${pdfX} ${pdfY} cm\n${colorCmd}\n${drawCmd}\nQ`);
219
+ }
220
+ else if (child.drawableType === "cxnSp") {
221
+ const conn = child;
222
+ const b = conn.bounds;
223
+ if (!b)
224
+ continue;
225
+ let w = emuToPx(b.cx);
226
+ let h = emuToPx(b.cy);
227
+ if (h === 0 && w > 0) {
228
+ h = 1;
229
+ w -= 1;
230
+ }
231
+ else if (w === 0 && h > 0) {
232
+ w = 1;
233
+ h -= 1;
234
+ }
235
+ if (w <= 0 && h <= 0)
236
+ continue;
237
+ const localX = emuToPx(b.x) - groupX + PDF_PADDING;
238
+ const localY = emuToPx(b.y) - groupY + PDF_PADDING;
239
+ const geom = conn.geometry?.preset ?? "line";
240
+ const drawCmd = getShapeDrawCmd(0, w, h, h, geom, conn.geometry?.adjustValues);
241
+ const color = shapeFillToColor(conn.fill, ctx) ?? "#000000";
242
+ const colorCmd = hexToRgCmd(color);
243
+ const pdfX = localX;
244
+ const pdfY = pageTotalH - localY - h;
245
+ out.push(`q\n1 0 0 1 ${pdfX} ${pdfY} cm\n${colorCmd}\n${drawCmd}\nQ`);
246
+ }
247
+ else if (child.drawableType === "grpSp") {
248
+ // Nested group: recursively collect children
249
+ const nestedGroup = child;
250
+ const sub = getTransformedChildren(nestedGroup);
251
+ collectChildPdfStreams(sub, groupX, groupY, pageTotalH, ctx, out);
252
+ }
253
+ }
254
+ }
255
+ /** Convert hex color string to PDF rg command. */
256
+ function hexToRgCmd(color) {
257
+ const hex = color.replace("#", "");
258
+ if (hex.length < 6)
259
+ return "0 0 0 rg";
260
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
261
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
262
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
263
+ return `${r.toFixed(3)} ${g.toFixed(3)} ${b.toFixed(3)} rg`;
264
+ }
265
+ /** Deep-clone a drawable and transform its bounds from group-local to slide-global coordinates. */
266
+ function transformChildBounds(drawable, groupBounds, childBounds, scaleX, scaleY) {
267
+ const clone = { ...drawable };
268
+ const b = clone.bounds;
269
+ if (b) {
270
+ clone.bounds = {
271
+ ...b,
272
+ x: groupBounds.x + (b.x - childBounds.x) * scaleX,
273
+ y: groupBounds.y + (b.y - childBounds.y) * scaleY,
274
+ cx: b.cx * scaleX,
275
+ cy: b.cy * scaleY,
276
+ };
277
+ }
278
+ // Recursively transform nested groups
279
+ if (clone.drawableType === "grpSp" && clone.children) {
280
+ const g = clone;
281
+ clone.children = g.children.map((c) => transformChildBounds(c, groupBounds, childBounds, scaleX, scaleY));
282
+ }
283
+ return clone;
284
+ }
285
+ function mapGraphicFrame(gf, styles, ctx, attachments) {
286
+ const b = gf.bounds;
287
+ if (!b)
288
+ return "";
289
+ // Chart or SmartArt: render fallback image if available, or render chart directly
290
+ if (!gf.tableData && gf.chartRId) {
291
+ if (gf.fallbackImageData)
292
+ return mapGraphicFrameFallbackImage(gf, b, ctx, attachments);
293
+ // No fallback image — try to render the chart as PDF
294
+ if (gf.chartData && attachments)
295
+ return renderChartAsPdf(gf.chartData, b, attachments);
296
+ return "";
297
+ }
298
+ if (!gf.tableData && gf.smartArtRId) {
299
+ return mapGraphicFrameFallbackImage(gf, b, ctx, attachments);
300
+ }
301
+ if (!gf.tableData)
302
+ return "";
303
+ const { rows, gridCols } = gf.tableData;
304
+ const x = emuToPx(b.x);
305
+ const y = emuToPx(b.y);
306
+ const w = emuToPx(b.cx);
307
+ const h = emuToPx(b.cy);
308
+ const cols = gridCols.map((cw) => `<col style="width:${emuToPx(cw)}px;">`).join("");
309
+ const textCtx = {
310
+ colorMap: ctx.colorMap,
311
+ colorScheme: ctx.colorScheme,
312
+ fontScheme: ctx.fontScheme,
313
+ slide: ctx.slide,
314
+ };
315
+ // Resolve table style defaults (accent-based fills/borders from tableStyleId)
316
+ const tblStyle = resolveTableStyle(gf.tableData, ctx);
317
+ // QL registers cell content CSS (margin, p, span) before td CSS, and table CSS last.
318
+ const rowsHtml = rows.map((row, rowIdx) => {
319
+ let colIdx = 0;
320
+ const cellsHtml = row.cells.map((cell) => {
321
+ if (cell.hMerge || cell.vMerge) {
322
+ colIdx++;
323
+ return "";
324
+ }
325
+ const span = cell.gridSpan ?? 1;
326
+ let colWEmu = 0;
327
+ for (let s = 0; s < span; s++)
328
+ colWEmu += gridCols[colIdx + s] ?? 0;
329
+ colIdx += span;
330
+ const styleDefaults = tblStyle ? getCellStyleDefaults(tblStyle, rowIdx, rows.length) : undefined;
331
+ return mapTableCell(cell, row.height, colWEmu, styles, textCtx, styleDefaults);
332
+ }).join("");
333
+ return `<tr>${cellsHtml}</tr>`;
334
+ }).join("");
335
+ const tableCss = `position:absolute; top:${y}; left:${x}; width:${w}; height:${h}; border-width: 0; cellspacing: 0; cellpadding: 0;`;
336
+ const tableClass = styles.addClass(tableCss);
337
+ return `<table class="${tableClass}">${cols}${rowsHtml}</table>`;
338
+ }
339
+ // OfficeImport uses a different EMU conversion for table row heights (÷101600 vs ÷12700)
340
+ const TABLE_ROW_HEIGHT_DIVISOR = 101600;
341
+ // Default cell margins: 91440 EMU per side (0.1 inch = 7.2pt).
342
+ // OfficeImport subtracts these in EMU space before pixel conversion.
343
+ const CELL_MARGIN_LR_EMU = 91440;
344
+ function mapTableCell(cell, rowHeight, colWidthEmu, styles, ctx, styleDefaults) {
345
+ // QL registers cell content CSS (margin, p, span) BEFORE td CSS.
346
+ let content = "";
347
+ if (cell.textBody) {
348
+ const innerW = Math.trunc((colWidthEmu - 2 * CELL_MARGIN_LR_EMU) / 12700);
349
+ const marginCss = `margin-top:3px; margin-left:7px; margin-bottom:3px; margin-right:7px; width:${innerW}px; overflow:hidden;`;
350
+ const marginClass = styles.addClass(marginCss);
351
+ const paragraphsHtml = mapTextParagraphs(cell.textBody, styles, ctx);
352
+ content = `<div class="${marginClass}">${paragraphsHtml}</div>`;
353
+ }
354
+ const cssParts = [];
355
+ cssParts.push(`height:${Math.trunc(rowHeight / TABLE_ROW_HEIGHT_DIVISOR)}px`);
356
+ const anchor = cell.anchor;
357
+ cssParts.push(`vertical-align:${anchor === "ctr" ? "middle" : anchor === "b" ? "bottom" : "top"}`);
358
+ // Background: explicit cell fill > table style default
359
+ const bg = fillToColor(cell.fill, ctx) ?? styleDefaults?.fill ?? null;
360
+ if (bg)
361
+ cssParts.push(`background-color:${bg}`);
362
+ if (!cell.borders && !styleDefaults) {
363
+ cssParts.push("border-style:none");
364
+ }
365
+ else if (cell.borders) {
366
+ cssParts.push("border-style:solid");
367
+ let maxBorderWidth = 0;
368
+ for (const side of ["top", "left", "bottom", "right"]) {
369
+ const stroke = cell.borders[side];
370
+ if (stroke?.width) {
371
+ const w = emuToPx(stroke.width);
372
+ if (w > maxBorderWidth)
373
+ maxBorderWidth = w;
374
+ }
375
+ }
376
+ cssParts.push(maxBorderWidth > 1 ? `border-width: ${maxBorderWidth}` : "border-width:thin");
377
+ for (const side of ["top", "left", "bottom", "right"]) {
378
+ const stroke = cell.borders[side];
379
+ const color = strokeToColor(stroke, ctx);
380
+ if (color)
381
+ cssParts.push(`border-${side}-color:${color}`);
382
+ }
383
+ }
384
+ else if (styleDefaults?.borderColor) {
385
+ // Apply table style borders
386
+ cssParts.push("border-style:solid");
387
+ cssParts.push(`border-width:${styleDefaults.borderWidth ?? "thin"}`);
388
+ cssParts.push(`border-top-color:${styleDefaults.borderColor}`);
389
+ cssParts.push(`border-left-color:${styleDefaults.borderColor}`);
390
+ cssParts.push(`border-bottom-color:${styleDefaults.borderColor}`);
391
+ cssParts.push(`border-right-color:${styleDefaults.borderColor}`);
392
+ }
393
+ else {
394
+ cssParts.push("border-style:none");
395
+ }
396
+ const tdClass = styles.addClass(cssParts.join(";") + ";");
397
+ const attrs = [];
398
+ if (cell.gridSpan && cell.gridSpan > 1)
399
+ attrs.push(`colspan="${cell.gridSpan}"`);
400
+ if (cell.rowSpan && cell.rowSpan > 1)
401
+ attrs.push(`rowspan="${cell.rowSpan}"`);
402
+ attrs.push(`class="${tdClass}"`);
403
+ return `<td ${attrs.join(" ")}>${content}</td>`;
404
+ }
405
+ // ── Chart rendering ─────────────────────────────────────────────────
406
+ // OfficeImport chart colors — full precision from decompressed QL PDFs
407
+ // These are sRGB values OfficeImport writes into the PDF content stream.
408
+ const CHART_COLORS_RGB = [
409
+ [0.3098039, 0.5058824, 0.7411765], // #4e81bd — blue
410
+ [0.7529412, 0.3137255, 0.3019608], // #bf4f4d — red/salmon
411
+ [0.6078431, 0.7333333, 0.3490196], // #9bbb59 — green
412
+ [0.5019608, 0.3921569, 0.6352941], // #8064a2 — purple
413
+ [0.2941176, 0.6745098, 0.7725490], // #4bacc6 — teal
414
+ [0.9647059, 0.5882353, 0.2745098], // #f79646 — orange
415
+ ];
416
+ /** Render a chart as a PDF image. */
417
+ function renderChartAsPdf(chart, bounds, attachments) {
418
+ const w = emuToPx(bounds.cx);
419
+ const h = emuToPx(bounds.cy);
420
+ const x = emuToPx(bounds.x);
421
+ const y = emuToPx(bounds.y);
422
+ let pdf = null;
423
+ if (chart.type === "bar")
424
+ pdf = renderColumnChart(chart, w, h);
425
+ else if (chart.type === "line")
426
+ pdf = renderLineChart(chart, w, h);
427
+ else if (chart.type === "pie")
428
+ pdf = renderPieChart(chart, w, h);
429
+ else if (chart.type === "area")
430
+ pdf = renderAreaChart(chart, w, h);
431
+ if (!pdf)
432
+ return "";
433
+ const idx = nextAttachmentIndex();
434
+ const name = `Attachment${idx}.pdf`;
435
+ attachments.set(name, pdf);
436
+ return `<img src="${name}" style="position:absolute; top:${y}; left:${x}; width:${w}; height:${h};">`;
437
+ }
438
+ /**
439
+ * Render a column/bar chart matching OfficeImport's exact layout.
440
+ * Measurements reverse-engineered from QL's decompressed PDF content stream.
441
+ */
442
+ function renderColumnChart(chart, w, h) {
443
+ const isCol = chart.direction !== "bar";
444
+ const nCats = chart.categories.length || 1;
445
+ const nSer = chart.series.length || 1;
446
+ // Axis max: for stacked, use sum per category; for clustered, use single max
447
+ const isStacked = chart.grouping === "stacked" || chart.grouping === "percentStacked";
448
+ let dataMax = 0;
449
+ if (isStacked) {
450
+ for (let c = 0; c < nCats; c++) {
451
+ let sum = 0;
452
+ for (const s of chart.series)
453
+ sum += s.values[c] ?? 0;
454
+ if (sum > dataMax)
455
+ dataMax = sum;
456
+ }
457
+ }
458
+ else {
459
+ for (const s of chart.series)
460
+ for (const v of s.values)
461
+ if (v > dataMax)
462
+ dataMax = v;
463
+ }
464
+ // Horizontal bar chart uses lineChartAxisScale (unit-based); column chart uses niceAxisMax (headroom-based)
465
+ if (!isCol) {
466
+ const { axisMax: hbarMax, nTicks: hbarTicks } = lineChartAxisScale(dataMax);
467
+ if (hbarMax === 0)
468
+ return buildChartPdf(w, h, "");
469
+ return renderHorizontalBarChart(chart, w, h, nCats, nSer, hbarMax, hbarTicks);
470
+ }
471
+ const axisMax = niceAxisMax(dataMax);
472
+ if (axisMax === 0)
473
+ return buildChartPdf(w, h, "");
474
+ // QL layout: plot area margins (from QL PDF analysis)
475
+ // For 648x324: left=82.5, bottom=41.5, plotW=521, plotH=242
476
+ const ml = Math.round(w * 0.127) + 0.5; // snap to half-pixel (82.5 for 648)
477
+ const mb = Math.round(h * 0.128) + 0.5; // (41.5 for 324)
478
+ const pw = Math.round(w * 0.804);
479
+ const ph = Math.round(h * 0.747);
480
+ // QL clip rect uses fractional height (h * 0.7448 = 241.3152 for 324)
481
+ // Bar heights are scaled using this fractional value, not the integer ph
482
+ const clipH = h * 0.7448;
483
+ const f = (v) => v.toFixed(4); // PDF coordinate formatter
484
+ const cmds = [];
485
+ // QL draws canvas/plot borders at 0% opacity (invisible via ExtGState)
486
+ // The actual visible border comes from WebKit rendering the <img> element
487
+ cmds.push("q Q q");
488
+ cmds.push(`/Cs1 cs 0 0 0 sc /Gs1 gs 0 0 ${w} ${h} re f`);
489
+ cmds.push(`2 w /Cs2 CS 0.95 0.95 0.95 SC 0 0 ${w} ${h} re S`);
490
+ cmds.push(`${f(ml)} ${f(mb)} ${pw} ${ph} re f`);
491
+ cmds.push(`1 w /Cs2 CS 0 0 0 SC /Gs2 gs ${f(ml)} ${f(mb)} ${pw} ${ph} re S`);
492
+ // Grid positions via Bresenham over gridRange = ph-1
493
+ const nTicks = axisMax <= 5 ? axisMax : axisMax <= 10 ? 5 : Math.min(axisMax / (axisMax >= 100 ? 100 : axisMax >= 10 ? 10 : 1), 8);
494
+ const gridRange = ph - 1;
495
+ const gridBase = Math.floor(gridRange / nTicks);
496
+ const gridRem = gridRange - gridBase * nTicks;
497
+ const gridPositions = [];
498
+ {
499
+ let pos = 0, err = Math.ceil(nTicks / 2);
500
+ for (let i = 0; i <= nTicks; i++) {
501
+ gridPositions.push(pos);
502
+ pos += gridBase;
503
+ err += gridRem;
504
+ if (err >= nTicks) {
505
+ pos++;
506
+ err -= nTicks;
507
+ }
508
+ }
509
+ }
510
+ const mbSnap = Math.floor(mb) + 0.5;
511
+ // Draw grid lines: black stroke at 25% opacity (Gs3)
512
+ cmds.push(`/Cs1 CS 0 0 0 SC /Gs3 gs`);
513
+ for (const gp of gridPositions) {
514
+ const gy = mbSnap + gp;
515
+ cmds.push(`${f(ml)} ${gy} m ${f(ml + pw)} ${gy} l S`);
516
+ }
517
+ cmds.push("Q");
518
+ // Clip to plot area
519
+ cmds.push("q");
520
+ cmds.push(`${f(ml)} ${f(mb)} ${pw + 1} ${ph + 1} re W n`);
521
+ {
522
+ const catW = pw / nCats;
523
+ if (isStacked) {
524
+ // Stacked: one bar per category, series stacked vertically
525
+ // QL uses 50% of category width for bars, Bresenham category stepping,
526
+ // integer leftPad = floor((floor(catW) - barW) / 2)
527
+ const barW = Math.round(catW * 0.5);
528
+ const intCatW = Math.floor(catW);
529
+ const leftPad = Math.floor((intCatW - barW) / 2);
530
+ // Bresenham category positions (initial error=1 matches QL)
531
+ const stkCatPos = [];
532
+ {
533
+ const base = Math.floor(pw / nCats), rem = pw - base * nCats;
534
+ let pos = 0, err = 1;
535
+ for (let c = 0; c < nCats; c++) {
536
+ stkCatPos.push(pos);
537
+ let step = base;
538
+ err += rem;
539
+ if (err >= nCats) {
540
+ step++;
541
+ err -= nCats;
542
+ }
543
+ pos += step;
544
+ }
545
+ }
546
+ for (let c = 0; c < nCats; c++) {
547
+ let cumVal = 0;
548
+ let cumH = 0;
549
+ for (let s = 0; s < nSer; s++) {
550
+ const val = chart.series[s]?.values[c] ?? 0;
551
+ cumVal += val;
552
+ const cumTop = Math.round((cumVal / axisMax) * clipH);
553
+ const barH = cumTop - cumH;
554
+ const bx = ml + stkCatPos[c] + leftPad;
555
+ const by = mb + cumH;
556
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
557
+ cmds.push(`/Cs1 cs ${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} sc`);
558
+ cmds.push(`${f(bx)} ${f(by)} ${barW} ${barH} re f`);
559
+ cmds.push(`0.75 w /Cs1 CS ${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} SC`);
560
+ cmds.push(`${f(bx)} ${f(by)} ${barW} ${barH} re S`);
561
+ cumH = cumTop;
562
+ }
563
+ }
564
+ }
565
+ else {
566
+ // Clustered: bars side by side
567
+ // QL: integer barW, Bresenham category stepping, integer leftPad
568
+ const barW = Math.floor(catW / (nSer + 1));
569
+ const leftPad = Math.floor(catW / (2 * (nSer + 1)));
570
+ // Bresenham category positions (initial error=1 matches QL bar spacing)
571
+ const barCatPos = [];
572
+ {
573
+ const base = Math.floor(pw / nCats), rem = pw - base * nCats;
574
+ let pos = 0, err = 1;
575
+ for (let c = 0; c < nCats; c++) {
576
+ barCatPos.push(pos);
577
+ let step = base;
578
+ err += rem;
579
+ if (err >= nCats) {
580
+ step++;
581
+ err -= nCats;
582
+ }
583
+ pos += step;
584
+ }
585
+ }
586
+ for (let c = 0; c < nCats; c++) {
587
+ for (let s = 0; s < nSer; s++) {
588
+ const val = chart.series[s]?.values[c] ?? 0;
589
+ const barH = Math.round((val / axisMax) * clipH);
590
+ const bx = ml + leftPad + barCatPos[c] + s * barW;
591
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
592
+ cmds.push(`${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} rg`);
593
+ cmds.push(`${f(bx)} ${f(mb)} ${barW} ${barH} re f`);
594
+ cmds.push(`${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} RG 0.75 w`);
595
+ cmds.push(`${f(bx)} ${f(mb)} ${barW} ${barH} re S`);
596
+ }
597
+ }
598
+ }
599
+ }
600
+ cmds.push("Q"); // end clip
601
+ // Axis tick marks (QL style: ticks at category boundaries, snapped to .5)
602
+ cmds.push("q /Cs1 CS 0 0 0 SC");
603
+ for (let c = 0; c < nCats; c++) {
604
+ const tx = Math.floor(ml + c * pw / nCats) + 0.5;
605
+ cmds.push(`${tx} ${f(mb)} m ${tx} ${f(mb - 2)} l S`);
606
+ }
607
+ // Bottom axis line
608
+ cmds.push(`${f(ml)} ${f(mb)} m ${f(ml + pw)} ${f(mb)} l S`);
609
+ // Y-axis ticks (reuse Bresenham grid positions)
610
+ for (const gp of gridPositions) {
611
+ const gy = mbSnap + gp;
612
+ cmds.push(`${f(ml)} ${gy} m ${f(ml - 3)} ${gy} l S`);
613
+ }
614
+ // Left axis line from bottom to top grid line
615
+ cmds.push(`${f(ml)} ${f(mb)} m ${f(ml)} ${mbSnap + gridRange} l S`);
616
+ // Category labels: Helvetica 12pt, centered below category
617
+ // QL uses Bresenham stepping (init_err=2) for category boundaries, then centers labels
618
+ cmds.push("/Cs1 cs 0 0 0 sc");
619
+ // Compute category boundaries via Bresenham (init_err=2 matches QL's distribution)
620
+ const catBounds = [];
621
+ {
622
+ const base = Math.floor(pw / nCats), rem = pw - base * nCats;
623
+ let pos = 0, err = 2;
624
+ for (let c = 0; c <= nCats; c++) {
625
+ catBounds.push(pos);
626
+ let step = base;
627
+ err += rem;
628
+ if (err >= nCats) {
629
+ step++;
630
+ err -= nCats;
631
+ }
632
+ pos += step;
633
+ }
634
+ }
635
+ const mlInt = Math.floor(ml);
636
+ for (let c = 0; c < nCats; c++) {
637
+ const label = chart.categories[c] ?? "";
638
+ if (!label)
639
+ continue;
640
+ const textW = chartTextWidth(label, 12, HELV_WIDTHS);
641
+ let catCenter;
642
+ if (isStacked) {
643
+ // Stacked: center on bar midpoint (barX + barW/2)
644
+ const stkCatW = pw / nCats;
645
+ const intCatW = Math.floor(stkCatW);
646
+ const barW = Math.round(stkCatW * 0.5);
647
+ const leftPad = Math.floor((intCatW - barW) / 2);
648
+ const base = Math.floor(pw / nCats), rem = pw - base * nCats;
649
+ let pos = 0, err = 1;
650
+ for (let i = 0; i < c; i++) {
651
+ let step = base;
652
+ err += rem;
653
+ if (err >= nCats) {
654
+ step++;
655
+ err -= nCats;
656
+ }
657
+ pos += step;
658
+ }
659
+ catCenter = ml + pos + leftPad + barW / 2;
660
+ }
661
+ else {
662
+ catCenter = mlInt + (catBounds[c] + catBounds[c + 1]) / 2;
663
+ }
664
+ const labelX = Math.floor(catCenter) - Math.floor(textW / 2) + 0.5;
665
+ cmds.push(`BT -0.0002 Tc 12 0 0 12 ${labelX.toFixed(1)} ${(mb - 9).toFixed(1)} Tm /F1 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
666
+ }
667
+ // Value labels: Arial 10pt, right-aligned
668
+ // QL: single-char labels get no Tc; multi-char get 0.0002 Tc + 0 Tc reset
669
+ for (let i = 0; i <= nTicks; i++) {
670
+ const val = (axisMax / nTicks) * i;
671
+ const label = formatAxisValue(val);
672
+ const textW = chartTextWidth(label, 10, ARIAL_WIDTHS);
673
+ const labelX = Math.floor(ml - 7.5 - textW) + 0.5;
674
+ const gy = mbSnap + gridPositions[i];
675
+ if (label.length > 1) {
676
+ cmds.push(`BT 0.0002 Tc 10 0 0 10 ${labelX.toFixed(1)} ${(gy - 3).toFixed(1)} Tm /F2 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
677
+ }
678
+ else {
679
+ cmds.push(`BT 10 0 0 10 ${labelX.toFixed(1)} ${(gy - 3).toFixed(1)} Tm /F2 1 Tf (${pdfEscape(label)}) Tj ET`);
680
+ }
681
+ }
682
+ return buildChartPdf(w, h, cmds.join("\n"));
683
+ }
684
+ /**
685
+ * Render a horizontal bar chart as PDF matching OfficeImport's exact layout.
686
+ * Measurements reverse-engineered from QL's decompressed Attachment6.pdf content stream
687
+ * (python-pptx-charts.pptx slide 2, 648x324).
688
+ *
689
+ * Key differences from column chart:
690
+ * - Grid lines are VERTICAL (value axis on X, category axis on Y)
691
+ * - Bars grow rightward from the left axis
692
+ * - Category labels on Y-axis (ArialMT 10pt, right-aligned), value labels on X-axis (bottom)
693
+ * - Clip rect uses sub-pixel offsets: ml+0.1848, mb-0.1576
694
+ * - Ghost black paths drawn before each bar (QL hit-test artifacts)
695
+ */
696
+ function renderHorizontalBarChart(chart, w, h, nCats, nSer, axisMax, nTicks) {
697
+ // Same plot margins as column chart
698
+ const ml = Math.round(w * 0.127) + 0.5; // 82.5 for 648
699
+ const mb = Math.round(h * 0.128) + 0.5; // 41.5 for 324
700
+ const pw = Math.round(w * 0.804); // 521 for 648
701
+ const ph = Math.round(h * 0.747); // 242 for 324
702
+ const f = (v) => v.toFixed(4);
703
+ const cmds = [];
704
+ // 1. Background: transparent black fill + gray border + white plot + invisible plot border
705
+ // QL uses /Cs1 (ICCBased sRGB) for fill and /Cs2 for stroke color spaces
706
+ cmds.push("q Q q /Cs1 cs 0 0 0 sc /Gs1 gs");
707
+ cmds.push(`0 0 ${w} ${h} re f`);
708
+ cmds.push(`2 w /Cs2 CS 0.95 0.95 0.95 SC 0 0 ${w} ${h} re S`);
709
+ cmds.push(`${f(ml)} ${f(mb)} ${pw} ${ph} re f`);
710
+ cmds.push(`1 w /Cs2 CS 0 0 0 SC /Gs2 gs ${f(ml)} ${f(mb)} ${pw} ${ph} re S`);
711
+ // 2. VERTICAL grid lines at value positions (25% alpha)
712
+ // Bresenham over pw with nTicks intervals, initial error=1
713
+ const gridXPos = [];
714
+ {
715
+ const base = Math.floor(pw / nTicks), rem = pw - base * nTicks;
716
+ let pos = 0, err = 1;
717
+ for (let i = 0; i <= nTicks; i++) {
718
+ gridXPos.push(pos);
719
+ let step = base;
720
+ err += rem;
721
+ if (err >= nTicks) {
722
+ step++;
723
+ err -= nTicks;
724
+ }
725
+ pos += step;
726
+ }
727
+ }
728
+ // QL draws grid lines only for first nTicks positions (not rightmost edge)
729
+ cmds.push("/Cs1 CS 0 0 0 SC /Gs3 gs");
730
+ for (let i = 0; i < nTicks; i++) {
731
+ const gx = ml + gridXPos[i];
732
+ cmds.push(`${f(gx)} ${f(mb)} m ${f(gx)} ${f(mb + ph - 1)} l`);
733
+ }
734
+ cmds.push("S Q");
735
+ // 3. Category Y positions via Bresenham (ph-1 range, nCats intervals, error=1)
736
+ const catYPos = []; // relative positions from mb
737
+ {
738
+ const base = Math.floor((ph - 1) / nCats), rem = (ph - 1) - base * nCats;
739
+ let pos = 0, err = 1;
740
+ for (let i = 0; i <= nCats; i++) {
741
+ catYPos.push(pos);
742
+ let step = base;
743
+ err += rem;
744
+ if (err >= nCats) {
745
+ step++;
746
+ err -= nCats;
747
+ }
748
+ pos += step;
749
+ }
750
+ }
751
+ // Bar dimensions: barH and topPad computed from floor(ph/nCats) — the nominal category height
752
+ const intCatH = Math.floor(ph / nCats);
753
+ const barH = Math.floor(intCatH / (nSer + 1));
754
+ const topPad = Math.floor(intCatH / (2 * (nSer + 1)));
755
+ // Clip rect: sub-pixel precision matching QL exactly
756
+ const clipX = ml + 0.1848;
757
+ const clipY = mb - 0.1576;
758
+ const clipW = pw - 0.2672;
759
+ const clipH = ph - 0.6848;
760
+ cmds.push(`q ${clipX.toFixed(4)} ${clipY.toFixed(4)} ${clipW.toFixed(4)} ${clipH.toFixed(4)} re W n`);
761
+ cmds.push("/Cs1 cs 0 0 0 sc");
762
+ // 4. Bars: for each category (bottom to top), draw ghost path + colored bar
763
+ let firstBar = true;
764
+ for (let c = 0; c < nCats; c++) {
765
+ const catBase = clipY + catYPos[c];
766
+ for (let s = 0; s < nSer; s++) {
767
+ const val = chart.series[s]?.values[c] ?? 0;
768
+ const bw = Math.round((val / axisMax) * pw);
769
+ const by = catBase + topPad + s * barH;
770
+ // Ghost path: black fill quad (QL hit-test artifact, uses ml-based integer Y)
771
+ const ghostEndX = ml + bw;
772
+ const ghostBotY = mb + catYPos[c] + topPad + s * barH;
773
+ const ghostTopY = ghostBotY + barH;
774
+ cmds.push(`${f(ghostEndX)} ${f(ghostBotY)} m ${f(ml)} ${f(ghostTopY)} l ${f(ml)} ${f(ghostTopY)} l ${f(ml)} ${f(ghostBotY)} l h f`);
775
+ // Colored bar (clipX for x, sub-pixel y)
776
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
777
+ cmds.push(`${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} sc`);
778
+ cmds.push(`${clipX.toFixed(4)} ${f(by)} ${bw} ${barH} re f`);
779
+ // First bar sets line width + stroke colorspace; subsequent bars inherit
780
+ if (firstBar) {
781
+ cmds.push(`0.75 w /Cs1 CS ${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} SC`);
782
+ firstBar = false;
783
+ }
784
+ else {
785
+ cmds.push(`${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} SC`);
786
+ }
787
+ cmds.push(`${clipX.toFixed(4)} ${f(by)} ${bw} ${barH} re S`);
788
+ // Reset fill to black for next ghost path (QL skips after last bar)
789
+ if (c < nCats - 1 || s < nSer - 1)
790
+ cmds.push("0 0 0 sc");
791
+ }
792
+ }
793
+ cmds.push("Q");
794
+ // 5. X-axis: value tick marks + axis line
795
+ cmds.push("q /Cs1 CS 0 0 0 SC");
796
+ for (let i = 0; i <= nTicks; i++) {
797
+ const tx = ml + gridXPos[i];
798
+ cmds.push(`${f(tx)} ${f(mb)} m ${f(tx)} ${f(mb - 1)} l`);
799
+ }
800
+ cmds.push(`${f(ml)} ${f(mb)} m ${f(ml + pw)} ${f(mb)} l S`);
801
+ // 6. Value labels: ArialMT 10pt, centered under grid X positions
802
+ // First label (val=0): floor(gridX - textW/2) + 0.5
803
+ // Other labels: floor(gridX) - floor(textW/2) + 0.5
804
+ cmds.push("/Cs1 cs 0 0 0 sc");
805
+ for (let i = 0; i <= nTicks; i++) {
806
+ const val = (axisMax / nTicks) * i;
807
+ const label = formatAxisValue(val);
808
+ const textW = chartTextWidth(label, 10, ARIAL_WIDTHS);
809
+ const gridX = ml + gridXPos[i];
810
+ const labelX = i === 0
811
+ ? Math.floor(gridX - textW / 2) + 0.5
812
+ : Math.floor(gridX) - Math.floor(textW / 2) + 0.5;
813
+ const labelY = mb - 11; // QL: y=30.5 for mb=41.5
814
+ if (label.length > 1) {
815
+ cmds.push(`BT 0.0002 Tc 10 0 0 10 ${labelX.toFixed(1)} ${labelY.toFixed(1)} Tm /TT1 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
816
+ }
817
+ else {
818
+ cmds.push(`BT 10 0 0 10 ${labelX.toFixed(1)} ${labelY.toFixed(1)} Tm /TT1 1 Tf (${pdfEscape(label)}) Tj ET`);
819
+ }
820
+ }
821
+ // 7. Y-axis: category tick marks (3px left) + axis line
822
+ for (let i = 0; i <= nCats; i++) {
823
+ const ty = mb + catYPos[i];
824
+ cmds.push(`${f(ml)} ${f(ty)} m ${f(ml - 3)} ${f(ty)} l`);
825
+ }
826
+ cmds.push(`${f(ml)} ${f(mb)} m ${f(ml)} ${f(mb + ph - 1)} l S`);
827
+ // 8. Category labels: ArialMT 10pt, right-aligned to ml-5.5
828
+ // labelX = floor(ml - 5.5 - textW) + 0.5
829
+ // labelY = floor(catCenter) - 2.5
830
+ for (let c = 0; c < nCats; c++) {
831
+ const label = chart.categories[c] ?? "";
832
+ if (!label)
833
+ continue;
834
+ const textW = chartTextWidth(label, 10, ARIAL_WIDTHS);
835
+ const labelX = Math.floor(ml - 5.5 - textW) + 0.5;
836
+ const catBottom = mb + catYPos[c];
837
+ const catTop = mb + catYPos[c + 1];
838
+ const catCenter = (catBottom + catTop) / 2;
839
+ const labelY = Math.floor(catCenter) - 2.5;
840
+ cmds.push(`BT 10 0 0 10 ${labelX.toFixed(1)} ${labelY.toFixed(1)} Tm /TT1 1 Tf`);
841
+ cmds.push(`[ ${pdfKernedText(label)} ] TJ ET`);
842
+ }
843
+ cmds.push("Q");
844
+ return buildChartPdf(w, h, cmds.join("\n"));
845
+ }
846
+ /** Compute a nice axis maximum matching OfficeImport's chart axis rounding.
847
+ * Verified from QL PDFs: 3.5→5, 58→100, 175→400, 195→400, 1600→4000 */
848
+ function niceAxisMax(dataMax) {
849
+ if (dataMax <= 0)
850
+ return 1;
851
+ // QL adds ~40% headroom then rounds to next nice number (1,2,4,5,8,10 series)
852
+ const target = dataMax * 1.4;
853
+ if (target <= 5)
854
+ return Math.ceil(target);
855
+ if (target <= 10)
856
+ return 10;
857
+ const magnitude = Math.pow(10, Math.floor(Math.log10(target)));
858
+ const normalized = target / magnitude;
859
+ const niceValues = [1, 2, 4, 5, 8, 10];
860
+ for (const n of niceValues) {
861
+ if (normalized <= n)
862
+ return n * magnitude;
863
+ }
864
+ return Math.ceil(target / magnitude) * magnitude;
865
+ }
866
+ /**
867
+ * Compute axis max and tick interval for line charts.
868
+ * Reverse-engineered from QL's decompressed PDF: pick nice unit, ensure >= 4 intervals.
869
+ */
870
+ function lineChartAxisScale(dataMax) {
871
+ if (dataMax <= 0)
872
+ return { axisMax: 1, unit: 1, nTicks: 1 };
873
+ const mag = Math.pow(10, Math.floor(Math.log10(dataMax)));
874
+ const norm = dataMax / mag;
875
+ const unit = norm <= 1 ? 0.5 * mag : norm <= 5 ? mag : 2 * mag;
876
+ const nTicks = Math.max(4, Math.ceil(dataMax / unit));
877
+ return { axisMax: unit * nTicks, unit, nTicks };
878
+ }
879
+ /** Render a line chart as PDF matching OfficeImport's layout.
880
+ * Measurements from QL's decompressed Attachment3.pdf content stream:
881
+ * Plot: 71.5 41.5 443 242 | lines: 2.25w round cap/join | grid: 25% alpha
882
+ * Categories: Helvetica 12pt | values: ArialMT 10pt | legend: 10x10 box + 10pt */
883
+ function renderLineChart(chart, w, h) {
884
+ const nCats = chart.categories.length || 1;
885
+ let dataMax = 0;
886
+ for (const s of chart.series)
887
+ for (const v of s.values)
888
+ if (v > dataMax)
889
+ dataMax = v;
890
+ const { axisMax, unit, nTicks } = lineChartAxisScale(dataMax);
891
+ if (axisMax === 0)
892
+ return buildChartPdf(w, h, "");
893
+ const hasLegend = chart.legendPos === "r" && chart.series.some(s => s.name);
894
+ const f = (v) => v.toFixed(4);
895
+ // QL layout: with legend ml=71.5/pw=443, without ml=82.5/pw=521 (for 648x324)
896
+ const mlInt = hasLegend ? Math.round(w * 0.10957) : Math.round(w * 0.127315 - 0.5);
897
+ const ml = mlInt + 0.5;
898
+ const mb = Math.round(h * 0.12654) + 0.5;
899
+ const pw = hasLegend ? Math.round(w * 0.68364) : Math.round(w * 0.803858);
900
+ const ph = Math.round(h * 0.74691);
901
+ const catW = pw / nCats;
902
+ const mbInt = Math.round(mb - 0.5);
903
+ // Grid Y positions: QL snaps to round(mbInt + i*(ph-1)/nTicks) + 0.5
904
+ const gridY = [];
905
+ for (let i = 0; i <= nTicks; i++)
906
+ gridY.push(Math.round(mbInt + i * (ph - 1) / nTicks) + 0.5);
907
+ // Right edge of plot: QL draws grid/axis to ml + pw - 1, not ml + pw
908
+ const plotRight = ml + pw - 1;
909
+ // Tick X positions: QL uses floor(mlInt + i * catW) + 0.5
910
+ const tickX = [];
911
+ for (let i = 0; i < nCats; i++)
912
+ tickX.push(Math.floor(mlInt + i * catW) + 0.5);
913
+ // Data point X: QL uses mlInt + round((i + 0.5) * catW) - 0.5
914
+ const dataX = [];
915
+ for (let i = 0; i < nCats; i++) {
916
+ dataX.push(mlInt + Math.round((i + 0.5) * catW) - 0.5);
917
+ }
918
+ const cmds = [];
919
+ // 1. Background (QL: transparent fill + light gray border)
920
+ cmds.push("q Q q");
921
+ cmds.push("0 0 0 rg /Gs1 gs");
922
+ cmds.push(`0 0 ${w} ${h} re f`);
923
+ cmds.push(`2 w 0.95 0.95 0.95 RG`);
924
+ cmds.push(`0 0 ${w} ${h} re S`);
925
+ // 2. Plot area: white fill + invisible black border
926
+ cmds.push(`${f(ml)} ${f(mb)} ${pw} ${ph} re f`);
927
+ cmds.push(`1 w 0 0 0 RG /Gs2 gs`);
928
+ cmds.push(`${f(ml)} ${f(mb)} ${pw} ${ph} re S`);
929
+ // 3. Grid lines: black at 25% alpha
930
+ cmds.push("0 0 0 RG /Gs3 gs");
931
+ for (let i = 0; i <= nTicks; i++) {
932
+ cmds.push(`${f(ml)} ${gridY[i]} m ${f(plotRight)} ${gridY[i]} l`);
933
+ }
934
+ cmds.push("S");
935
+ cmds.push("Q");
936
+ // 4. Data lines (clipped to plot area, 2.25pt round cap/join)
937
+ // QL clip rect offsets from plot area: -0.24592, -0.1576 (inset slightly)
938
+ const clipX = (ml - 0.24592).toFixed(5);
939
+ const clipY = (mb - 0.1576).toFixed(4);
940
+ const clipW = (pw - 0.3771).toFixed(4);
941
+ const clipH = (ph - 0.6848).toFixed(4);
942
+ cmds.push(`q ${clipX} ${clipY} ${clipW} ${clipH} re W n`);
943
+ cmds.push("2.25 w 1 J 1 j");
944
+ // QL draws series 0 first (blue = 2025), then series 1 (red = 2026)
945
+ for (let s = 0; s < chart.series.length; s++) {
946
+ const ser = chart.series[s];
947
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
948
+ cmds.push(`${r.toFixed(7)} ${g.toFixed(7)} ${b.toFixed(7)} RG`);
949
+ for (let c = 0; c < ser.values.length; c++) {
950
+ const x = dataX[c];
951
+ // Data Y: QL uses mbInt + floor(value * ph / axisMax) + 0.5
952
+ const yRel = ser.values[c] * ph / axisMax;
953
+ const y = mbInt + Math.floor(yRel) + 0.5;
954
+ cmds.push(c === 0 ? `${x} ${y} m` : `${x} ${y} l`);
955
+ }
956
+ cmds.push("S");
957
+ }
958
+ cmds.push("Q");
959
+ // 5. X-axis: tick marks + axis line + category labels
960
+ cmds.push("q 0 0 0 RG 0 0 0 rg");
961
+ for (let i = 0; i < nCats; i++) {
962
+ cmds.push(`${tickX[i]} ${f(mb)} m ${tickX[i]} ${f(mb - 2)} l`);
963
+ }
964
+ cmds.push(`${f(ml)} ${f(mb)} m ${f(plotRight)} ${f(mb)} l S`);
965
+ // Category labels (Helvetica 12pt, centered on data X using font metrics)
966
+ for (let i = 0; i < nCats; i++) {
967
+ const label = chart.categories[i] ?? "";
968
+ if (!label)
969
+ continue;
970
+ const tw = chartTextWidth(label, 12, HELV_WIDTHS);
971
+ const labelX = Math.round(dataX[i] - tw / 2) + 0.5;
972
+ cmds.push(`BT 12 0 0 12 ${labelX.toFixed(1)} ${(mb - 9).toFixed(1)} Tm /F1 1 Tf`);
973
+ cmds.push(`[ ${pdfKernedText(label)} ] TJ ET`);
974
+ }
975
+ // 6. Y-axis: tick marks + axis line + value labels
976
+ for (let i = 0; i <= nTicks; i++) {
977
+ cmds.push(`${f(ml)} ${gridY[i]} m ${f(ml - 3)} ${gridY[i]} l`);
978
+ }
979
+ cmds.push(`${f(ml)} ${gridY[0]} m ${f(ml)} ${gridY[nTicks]} l S`);
980
+ // Value labels (ArialMT 10pt, right-aligned with 5pt gap from tick edge)
981
+ for (let i = 0; i <= nTicks; i++) {
982
+ const val = Math.round(unit * i);
983
+ const label = formatAxisValue(val);
984
+ const tw = chartTextWidth(label, 10, ARIAL_WIDTHS);
985
+ const labelX = mlInt - 3 - Math.round(tw) - 5 + 0.5;
986
+ const labelY = gridY[i] - 3;
987
+ if (val === 0) {
988
+ cmds.push(`BT 10 0 0 10 ${labelX.toFixed(1)} ${labelY.toFixed(1)} Tm /F2 1 Tf (${pdfEscape(label)}) Tj ET`);
989
+ }
990
+ else {
991
+ cmds.push(`BT 0.0002 Tc 10 0 0 10 ${labelX.toFixed(1)} ${labelY.toFixed(1)} Tm /F2 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
992
+ }
993
+ }
994
+ cmds.push("Q");
995
+ // 7. Legend (right side)
996
+ if (hasLegend) {
997
+ const legendX = ml + pw + 25;
998
+ const sx = Math.round(legendX) - 0.5;
999
+ const legendClipX = (sx + 0.284).toFixed(3);
1000
+ const legendClipW = (w - (sx + 0.284) - 12.96).toFixed(3);
1001
+ cmds.push(`q ${legendClipX} 3.24 ${legendClipW} ${h - 6.48} re W n`);
1002
+ cmds.push("2.25 w 1 J 1 j");
1003
+ // QL legend: bottom entry at plotMidY, each subsequent 25px higher
1004
+ const plotMidY = mb + ph / 2;
1005
+ const nSeries = chart.series.length;
1006
+ let drawIdx = 0;
1007
+ for (let s = nSeries - 1; s >= 0; s--) {
1008
+ const ser = chart.series[s];
1009
+ if (!ser.name)
1010
+ continue;
1011
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
1012
+ const rc = r.toFixed(7), gc = g.toFixed(7), bc = b.toFixed(7);
1013
+ const entryY = plotMidY + (nSeries - 1 - drawIdx) * 25;
1014
+ // Color box (stroke only)
1015
+ cmds.push(`${rc} ${gc} ${bc} RG`);
1016
+ cmds.push(`${sx} ${entryY} 10 10 re S`);
1017
+ // Series name
1018
+ cmds.push(`0 0 0 rg BT 0.0002 Tc 10 0 0 10 ${(sx + 14).toFixed(1)} ${(entryY + 2).toFixed(1)} Tm /F2 1 Tf`);
1019
+ cmds.push(`(${pdfEscape(ser.name)}) Tj 0 Tc ET`);
1020
+ drawIdx++;
1021
+ }
1022
+ cmds.push("Q");
1023
+ }
1024
+ return buildChartPdf(w, h, cmds.join("\n"));
1025
+ }
1026
+ /**
1027
+ * Render a pie chart as PDF matching OfficeImport's exact output.
1028
+ *
1029
+ * OfficeImport renders one full circle per SERIES (not per data point).
1030
+ * Layout: gray border, pie centered in left ~83% of page, legend swatches on right.
1031
+ * Circle uses standard 4-segment cubic bezier approximation (kappa = 4*(sqrt(2)-1)/3).
1032
+ *
1033
+ * Geometry reverse-engineered from QL's decompressed PDF content stream:
1034
+ * Page 576x324: cx=260.0582, cy=162, r=108.5918
1035
+ * cx ≈ w*0.4515, cy = h/2, r ≈ h*0.335
1036
+ * Legend clip: x=w*0.833, y=h*0.01, w=w*0.147, h=h*0.98
1037
+ */
1038
+ function renderPieChart(chart, w, h) {
1039
+ const nSer = chart.series.length;
1040
+ if (nSer === 0)
1041
+ return buildPdf(w, h, "");
1042
+ const kappa = 4 * (Math.sqrt(2) - 1) / 3; // ≈ 0.5522847498
1043
+ const f = (v) => v.toFixed(4); // coordinates
1044
+ const fc = (v) => v.toFixed(7); // colors (QL uses 7 decimal places)
1045
+ // ── Layout (from QL) ──
1046
+ const cx = w * 0.4515;
1047
+ const cy = h / 2;
1048
+ const r = h * 0.335;
1049
+ // Legend swatch: half-pixel-aligned, vertically centered
1050
+ const swatchSize = 10;
1051
+ const entryH = 14; // swatch height + gap between entries
1052
+ const swatchX = Math.round(w * 0.833) - 0.5; // QL uses round(legX) - 0.5
1053
+ const cmds = [];
1054
+ // ── Background: gray border (QL draws transparent black fill then gray stroke) ──
1055
+ cmds.push(`2 w 0.95 0.95 0.95 RG 0 0 ${w} ${h} re S`);
1056
+ // ── One full circle per series ──
1057
+ for (let s = 0; s < nSer; s++) {
1058
+ const [cr, cg, cb] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
1059
+ cmds.push(`${fc(cr)} ${fc(cg)} ${fc(cb)} rg`);
1060
+ // Move to center, line to top of circle
1061
+ cmds.push(`${f(cx)} ${f(cy)} m`);
1062
+ cmds.push(`${f(cx)} ${f(cy + r)} l`);
1063
+ // 4 bezier arcs: top->right->bottom->left->top
1064
+ const k = r * kappa;
1065
+ cmds.push(`${f(cx + k)} ${f(cy + r)} ${f(cx + r)} ${f(cy + k)} ${f(cx + r)} ${f(cy)} c`);
1066
+ cmds.push(`${f(cx + r)} ${f(cy - k)} ${f(cx + k)} ${f(cy - r)} ${f(cx)} ${f(cy - r)} c`);
1067
+ cmds.push(`${f(cx - k)} ${f(cy - r)} ${f(cx - r)} ${f(cy - k)} ${f(cx - r)} ${f(cy)} c`);
1068
+ cmds.push(`${f(cx - r)} ${f(cy + k)} ${f(cx - k)} ${f(cy + r)} ${f(cx)} ${f(cy + r)} c`);
1069
+ cmds.push(`${f(cx)} ${f(cy)} l f`);
1070
+ // ── Legend swatch ──
1071
+ // QL places swatch at half-pixel offset from center; for 1 series: y = cy + 0.5
1072
+ const sy = cy - (nSer - 1) * entryH / 2 + s * entryH + 0.5;
1073
+ cmds.push(`${fc(cr)} ${fc(cg)} ${fc(cb)} rg`);
1074
+ cmds.push(`${swatchX} ${f(sy)} ${swatchSize} ${swatchSize} re f`);
1075
+ cmds.push(`0.75 w 1 J 1 j ${fc(cr)} ${fc(cg)} ${fc(cb)} RG`);
1076
+ cmds.push(`${swatchX} ${f(sy)} ${swatchSize} ${swatchSize} re S`);
1077
+ }
1078
+ return buildPdf(w, h, cmds.join("\n"));
1079
+ }
1080
+ /** Render an area chart as PDF matching OfficeImport's exact layout.
1081
+ * Layout reverse-engineered from QL's decompressed Attachment6.pdf content stream.
1082
+ * Key: transparent bg, invisible plot border, 25%-alpha grid, area fill+stroke,
1083
+ * tick marks, axis labels (Helvetica/Arial), legend with color swatch. */
1084
+ function renderAreaChart(chart, w, h) {
1085
+ const nCats = chart.categories.length || 1;
1086
+ let dataMax = 0;
1087
+ for (const s of chart.series)
1088
+ for (const v of s.values)
1089
+ if (v > dataMax)
1090
+ dataMax = v;
1091
+ const axisMax = niceAxisMax(dataMax);
1092
+ if (axisMax === 0)
1093
+ return buildChartPdf(w, h, "");
1094
+ // QL area chart layout (from decompressed PDF):
1095
+ // Plot area at (71.5, 41.5, 443, 242) for 648x324 chart.
1096
+ // Legend area on right side starting at ~539.
1097
+ const ml = Math.round(w * 0.10957); // 71 for 648
1098
+ const mb = Math.round(h * 0.12654); // 41 for 324
1099
+ const pw = Math.round(w * 0.68364); // 443 for 648
1100
+ const ph = Math.round(h * 0.74691); // 242 for 324
1101
+ // Grid line count — QL uses value-based ticks
1102
+ const nTicks = computeTickCount(axisMax);
1103
+ // Grid Y positions: floor(mb + i*ph/nTicks) + 0.5, clamped to mb+ph-1
1104
+ const gridY = [];
1105
+ for (let i = 0; i <= nTicks; i++) {
1106
+ const y = Math.min(Math.floor(mb + i * ph / nTicks), mb + ph - 1);
1107
+ gridY.push(y + 0.5);
1108
+ }
1109
+ // Pre-compute tick X positions — floor(ml + i*pw/nCats) + 0.5
1110
+ const tickX = [];
1111
+ for (let i = 0; i < nCats; i++)
1112
+ tickX.push(Math.floor(ml + i * pw / nCats) + 0.5);
1113
+ // Data point X: midpoint of category zone (integer center + 0.5)
1114
+ const dataX = [];
1115
+ for (let i = 0; i < nCats; i++) {
1116
+ const left = Math.floor(ml + i * pw / nCats);
1117
+ const right = Math.floor(ml + (i + 1) * pw / nCats);
1118
+ dataX.push(Math.floor((left + right) / 2) + 0.5);
1119
+ }
1120
+ // Data point Y: interpolate within grid intervals using round()
1121
+ const tickVal = axisMax / nTicks;
1122
+ const dataY = [];
1123
+ for (const ser of chart.series) {
1124
+ for (let c = 0; c < ser.values.length; c++) {
1125
+ const v = ser.values[c];
1126
+ const gridIdx = Math.min(Math.floor(v / tickVal), nTicks - 1);
1127
+ const frac = (v - gridIdx * tickVal) / tickVal;
1128
+ const yLow = gridY[gridIdx];
1129
+ const yHigh = gridY[gridIdx + 1] ?? gridY[nTicks];
1130
+ const yRaw = yLow + frac * (yHigh - yLow);
1131
+ // QL snaps to half-pixel: round to nearest integer then add 0.5
1132
+ dataY.push(Math.round(yRaw - 0.5) + 0.5);
1133
+ }
1134
+ }
1135
+ const f = (v) => v.toFixed(4);
1136
+ const cmds = [];
1137
+ // 1. Background: transparent fill + gray border (QL: ca=0 fill, 0.95 stroke)
1138
+ cmds.push("q Q q");
1139
+ cmds.push("0 0 0 rg /Gs1 gs");
1140
+ cmds.push(`0 0 ${w} ${h} re f`);
1141
+ cmds.push(`2 w 0.95 0.95 0.95 RG`);
1142
+ cmds.push(`0 0 ${w} ${h} re S`);
1143
+ // 2. Plot area: white fill + invisible black border
1144
+ cmds.push(`${ml}.5 ${mb}.5 ${pw} ${ph} re f`);
1145
+ cmds.push(`1 w 0 0 0 RG /Gs2 gs`);
1146
+ cmds.push(`${ml}.5 ${mb}.5 ${pw} ${ph} re S`);
1147
+ // 3. Grid lines: black at 25% alpha
1148
+ cmds.push("0 0 0 SC /Gs3 gs");
1149
+ for (let i = 0; i <= nTicks; i++) {
1150
+ cmds.push(`${ml}.5 ${gridY[i]} m ${ml + pw - 1}.5 ${gridY[i]} l`);
1151
+ }
1152
+ cmds.push("S");
1153
+ cmds.push("Q");
1154
+ // 4. Area fill (clipped to plot area)
1155
+ // QL clip rect uses sub-pixel precision: ml+0.25408, mb+0.3424, pw-0.377, ph-0.685
1156
+ const clipX = (ml + 0.25408).toFixed(5);
1157
+ const clipY = (mb + 0.3424).toFixed(4);
1158
+ const clipW = (pw - 0.3771).toFixed(4);
1159
+ const clipH = (ph - 0.6848).toFixed(4);
1160
+ cmds.push(`q ${clipX} ${clipY} ${clipW} ${clipH} re W n`);
1161
+ for (let s = chart.series.length - 1; s >= 0; s--) {
1162
+ const ser = chart.series[s];
1163
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
1164
+ const rc = r.toFixed(7), gc = g.toFixed(7), bc = b.toFixed(7);
1165
+ // Fill polygon: first data point → all points → baseline → close
1166
+ cmds.push(`${rc} ${gc} ${bc} rg`);
1167
+ cmds.push(`${dataX[0]} ${dataY[s * nCats + 0]} m`);
1168
+ for (let c = 1; c < ser.values.length; c++) {
1169
+ cmds.push(`${dataX[c]} ${dataY[s * nCats + c]} l`);
1170
+ }
1171
+ // Down to baseline, across to first point's x, close and fill
1172
+ cmds.push(`${dataX[ser.values.length - 1]} ${mb}.5 l`);
1173
+ cmds.push(`${dataX[0]} ${mb}.5 l f`);
1174
+ // Stroke the top edge (same color, 0.75w, round cap+join)
1175
+ cmds.push(`0.75 w 1 J 1 j`);
1176
+ cmds.push(`${rc} ${gc} ${bc} SC`);
1177
+ cmds.push(`${dataX[0]} ${dataY[s * nCats + 0]} m`);
1178
+ for (let c = 1; c < ser.values.length; c++) {
1179
+ cmds.push(`${dataX[c]} ${dataY[s * nCats + c]} l`);
1180
+ }
1181
+ cmds.push("S");
1182
+ }
1183
+ cmds.push("Q");
1184
+ // 5. Category axis: tick marks + axis line + labels
1185
+ cmds.push("q 0 0 0 RG 0 0 0 rg");
1186
+ // Tick marks at category boundaries (2px tall, downward from baseline)
1187
+ for (let i = 0; i < nCats; i++) {
1188
+ cmds.push(`${tickX[i]} ${mb}.5 m ${tickX[i]} ${mb - 2}.5 l`);
1189
+ }
1190
+ // Bottom axis line
1191
+ cmds.push(`${ml}.5 ${mb}.5 m ${ml + pw - 1}.5 ${mb}.5 l S`);
1192
+ // Category labels: Helvetica 12pt, centered using Bresenham boundaries
1193
+ // QL uses Bresenham stepping (init_err=2) for category boundaries
1194
+ {
1195
+ const lineCatBounds = [];
1196
+ const base = Math.floor(pw / nCats), rem = pw - base * nCats;
1197
+ let pos = 0, err = 2;
1198
+ for (let c = 0; c <= nCats; c++) {
1199
+ lineCatBounds.push(pos);
1200
+ let step = base;
1201
+ err += rem;
1202
+ if (err >= nCats) {
1203
+ step++;
1204
+ err -= nCats;
1205
+ }
1206
+ pos += step;
1207
+ }
1208
+ const labelOrigin = ml + 0.5; // area chart uses half-pixel origin for centering
1209
+ for (let i = 0; i < nCats; i++) {
1210
+ const label = chart.categories[i] ?? "";
1211
+ const textW = chartTextWidth(label, 12, HELV_WIDTHS);
1212
+ const catCenter = labelOrigin + (lineCatBounds[i] + lineCatBounds[i + 1]) / 2;
1213
+ const labelX = Math.floor(catCenter) - Math.floor(textW / 2) + 0.5;
1214
+ cmds.push(`BT -0.0002 Tc 12 0 0 12 ${labelX.toFixed(1)} ${mb - 8.5} Tm /F1 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
1215
+ }
1216
+ }
1217
+ // 6. Value axis: tick marks (3px) + axis line + labels
1218
+ for (let i = 0; i <= nTicks; i++) {
1219
+ cmds.push(`${ml}.5 ${gridY[i]} m ${ml - 3}.5 ${gridY[i]} l`);
1220
+ }
1221
+ cmds.push(`${ml}.5 ${gridY[0]} m ${ml}.5 ${gridY[nTicks]} l S`);
1222
+ // Value labels: Arial 10pt, right-aligned 5pt before tick end (ml-3.5)
1223
+ for (let i = 0; i <= nTicks; i++) {
1224
+ const val = (axisMax / nTicks) * i;
1225
+ const label = formatAxisValue(val);
1226
+ const textW = chartTextWidth(label, 10, ARIAL_WIDTHS);
1227
+ const labelX = Math.floor(ml - 7.5 - textW) + 0.5;
1228
+ cmds.push(`BT 0.0002 Tc 10 0 0 10 ${labelX.toFixed(1)} ${(gridY[i] - 3).toFixed(1)} Tm /F2 1 Tf (${pdfEscape(label)}) Tj 0 Tc ET`);
1229
+ }
1230
+ cmds.push("Q");
1231
+ // 7. Legend (color swatch + series name)
1232
+ const legendX = ml + pw + 26;
1233
+ cmds.push(`q ${(legendX - 0.216).toFixed(3)} 3.24 ${(w - legendX + 0.216 - 12.96).toFixed(3)} ${h - 6.48} re W n`);
1234
+ for (let s = 0; s < chart.series.length; s++) {
1235
+ const [r, g, b] = CHART_COLORS_RGB[s % CHART_COLORS_RGB.length];
1236
+ const rc = r.toFixed(7), gc = g.toFixed(7), bc = b.toFixed(7);
1237
+ // QL: swatch at (539.5, 162.5) = (legendX-0.5, h/2+0.5)
1238
+ const sx = legendX - 0.5;
1239
+ const sy = Math.round(h / 2) + 0.5;
1240
+ cmds.push(`${rc} ${gc} ${bc} rg ${sx} ${sy} 10 10 re f`);
1241
+ cmds.push(`0.75 w 1 J 1 j ${rc} ${gc} ${bc} RG ${sx} ${sy} 10 10 re S`);
1242
+ const name = chart.series[s].name ?? "";
1243
+ cmds.push(`0 0 0 rg BT 10 0 0 10 ${sx + 14} ${sy + 2} Tm /F2 1 Tf`);
1244
+ cmds.push(`[ ${pdfKernedText(name)} ] TJ ET`);
1245
+ }
1246
+ cmds.push("Q");
1247
+ return buildChartPdf(w, h, cmds.join("\n"));
1248
+ }
1249
+ /** Compute tick count for value axis matching QL behavior.
1250
+ * QL: 5→5, 400→4, 4000→4 (axisMax / magnitude, with 5 for powers of 10). */
1251
+ function computeTickCount(axisMax) {
1252
+ if (axisMax <= 10)
1253
+ return axisMax;
1254
+ const magnitude = Math.pow(10, Math.floor(Math.log10(axisMax)));
1255
+ const n = axisMax / magnitude;
1256
+ return n === 1 ? 5 : n; // powers of 10 use 5 subdivisions
1257
+ }
1258
+ /** Format axis value label matching QL convention (e.g., "1.000" for thousands). */
1259
+ function formatAxisValue(val) {
1260
+ if (val === 0)
1261
+ return "0";
1262
+ if (val >= 1000)
1263
+ return (val / 1000).toFixed(3);
1264
+ if (Number.isInteger(val))
1265
+ return String(val);
1266
+ return val.toFixed(1);
1267
+ }
1268
+ /** Escape a string for PDF text. */
1269
+ function pdfEscape(s) {
1270
+ return s.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
1271
+ }
1272
+ /** Approximate kerned text for PDF TJ operator. */
1273
+ function pdfKernedText(s) {
1274
+ return `(${pdfEscape(s)})`;
1275
+ }
1276
+ // Helvetica Regular glyph widths (per 1000 em) — from QL's embedded font subsets
1277
+ const HELV_WIDTHS = { W: 944, M: 833, " ": 278, A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278, J: 500, K: 667, L: 556, N: 722, O: 778, P: 667, Q: 778, R: 722, S: 667, T: 611, U: 722, V: 667, X: 667, Y: 667, Z: 611, a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222, j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333, s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500 };
1278
+ // ArialMT glyph widths — from QL's embedded ArialMT subset
1279
+ const ARIAL_WIDTHS = { ".": 278, ",": 278, "0": 556, "1": 556, "2": 556, "3": 556, "4": 556, "5": 556, "6": 556, "7": 556, "8": 556, "9": 556, " ": 278, A: 667, B: 667, C: 722, D: 722, E: 667, F: 611, G: 778, H: 722, I: 278, J: 556, K: 667, L: 556, M: 833, N: 722, O: 778, P: 667, Q: 778, R: 722, S: 667, T: 611, U: 722, V: 667, W: 944, X: 667, Y: 667, Z: 611, a: 556, b: 556, c: 500, d: 556, e: 556, f: 278, g: 556, h: 556, i: 222, j: 222, k: 500, l: 222, m: 833, n: 556, o: 556, p: 556, q: 556, r: 333, s: 500, t: 278, u: 556, v: 500, w: 722, x: 500, y: 500, z: 500 };
1280
+ /** Compute text width in points given font size and width table. */
1281
+ function chartTextWidth(text, fontSize, widths) {
1282
+ let w = 0;
1283
+ for (const ch of text)
1284
+ w += widths[ch] ?? 556;
1285
+ return w * fontSize / 1000;
1286
+ }
1287
+ // ICC profiles from QL chart PDFs for correct WebKit color rendering.
1288
+ // Generic RGB (Apple) for /Cs1, sRGB IEC61966-2.1 for /Cs2.
1289
+ const ICC_GENERIC_RGB = Buffer.from("AAAHyGFwcGwCIAAAbW50clJHQiBYWVogB9kAAgAZAAsAGgALYWNzcEFQUEwAAAAAYXBwbAAAAAAAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1hcHBsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALZGVzYwAAAQgAAABvZHNjbQAAAXgAAAWKY3BydAAABwQAAAA4d3RwdAAABzwAAAAUclhZWgAAB1AAAAAUZ1hZWgAAB2QAAAAUYlhZWgAAB3gAAAAUclRSQwAAB4wAAAAOY2hhZAAAB5wAAAAsYlRSQwAAB4wAAAAOZ1RSQwAAB4wAAAAOZGVzYwAAAAAAAAAUR2VuZXJpYyBSR0IgUHJvZmlsZQAAAAAAAAAAAAAAFEdlbmVyaWMgUkdCIFByb2ZpbGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG1sdWMAAAAAAAAAHwAAAAxza1NLAAAAKAAAAYRkYURLAAAAJAAAAaxjYUVTAAAAJAAAAdB2aVZOAAAAJAAAAfRwdEJSAAAAJgAAAhh1a1VBAAAAKgAAAj5mckZVAAAAKAAAAmhodUhVAAAAKAAAApB6aFRXAAAAEgAAArhrb0tSAAAAFgAAAspuYk5PAAAAJgAAAuBjc0NaAAAAIgAAAwZoZUlMAAAAHgAAAyhyb1JPAAAAJAAAA0ZkZURFAAAALAAAA2ppdElUAAAAKAAAA5ZzdlNFAAAAJgAAAuB6aENOAAAAEgAAA75qYUpQAAAAGgAAA9BlbEdSAAAAIgAAA+pwdFBPAAAAJgAABAxubE5MAAAAKAAABDJlc0VTAAAAJgAABAx0aFRIAAAAJAAABFp0clRSAAAAIgAABH5maUZJAAAAKAAABKBockhSAAAAKAAABMhwbFBMAAAALAAABPBydVJVAAAAIgAABRxlblVTAAAAJgAABT5hckVHAAAAJgAABWQAVgFhAGUAbwBiAGUAYwBuAP0AIABSAEcAQgAgAHAAcgBvAGYAaQBsAEcAZQBuAGUAcgBlAGwAIABSAEcAQgAtAHAAcgBvAGYAaQBsAFAAZQByAGYAaQBsACAAUgBHAEIAIABnAGUAbgDoAHIAaQBjAEMepQB1ACAAaADsAG4AaAAgAFIARwBCACAAQwBoAHUAbgBnAFAAZQByAGYAaQBsACAAUgBHAEIAIABHAGUAbgDpAHIAaQBjAG8EFwQwBDMEMAQ7BEwEPQQ4BDkAIAQ/BEAEPgREBDAEOQQ7ACAAUgBHAEIAUAByAG8AZgBpAGwAIABnAOkAbgDpAHIAaQBxAHUAZQAgAFIAVgBCAMEAbAB0AGEAbADhAG4AbwBzACAAUgBHAEIAIABwAHIAbwBmAGkAbJAadSgAUgBHAEKCcl9pY8+P8Md8vBgAIABSAEcAQgAg1QS4XNMMx3wARwBlAG4AZQByAGkAcwBrACAAUgBHAEIALQBwAHIAbwBmAGkAbABPAGIAZQBjAG4A/QAgAFIARwBCACAAcAByAG8AZgBpAGwF5AXoBdUF5AXZBdwAIABSAEcAQgAgBdsF3AXcBdkAUAByAG8AZgBpAGwAIABSAEcAQgAgAGcAZQBuAGUAcgBpAGMAQQBsAGwAZwBlAG0AZQBpAG4AZQBzACAAUgBHAEIALQBQAHIAbwBmAGkAbABQAHIAbwBmAGkAbABvACAAUgBHAEIAIABnAGUAbgBlAHIAaQBjAG9mbpAaAFIARwBCY8+P8GWHTvZOAIIsACAAUgBHAEIAIDDXMO0w1TChMKQw6wOTA7UDvQO5A7oDzAAgA8ADwQO/A8YDrwO7ACAAUgBHAEIAUABlAHIAZgBpAGwAIABSAEcAQgAgAGcAZQBuAOkAcgBpAGMAbwBBAGwAZwBlAG0AZQBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBlAGwOQg4bDiMORA4fDiUOTAAgAFIARwBCACAOFw4xDkgOJw5EDhsARwBlAG4AZQBsACAAUgBHAEIAIABQAHIAbwBmAGkAbABpAFkAbABlAGkAbgBlAG4AIABSAEcAQgAtAHAAcgBvAGYAaQBpAGwAaQBHAGUAbgBlAHIAaQENAGsAaQAgAFIARwBCACAAcAByAG8AZgBpAGwAVQBuAGkAdwBlAHIAcwBhAGwAbgB5ACAAcAByAG8AZgBpAGwAIABSAEcAQgQeBDEESQQ4BDkAIAQ/BEAEPgREBDgEOwRMACAAUgBHAEIARwBlAG4AZQByAGkAYwAgAFIARwBCACAAUAByAG8AZgBpAGwAZQZFBkQGQQAgBioGOQYxBkoGQQAgAFIARwBCACAGJwZEBjkGJwZFAAB0ZXh0AAAAAENvcHlyaWdodCAyMDA3IEFwcGxlIEluYy4sIGFsbCByaWdodHMgcmVzZXJ2ZWQuAFhZWiAAAAAAAADzUgABAAAAARbPWFlaIAAAAAAAAHRNAAA97gAAA9BYWVogAAAAAAAAWnUAAKxzAAAXNFhZWiAAAAAAAAAoGgAAFZ8AALg2Y3VydgAAAAAAAAABAc0AAHNmMzIAAAAAAAEMQgAABd7///MmAAAHkgAA/ZH///ui///9owAAA9wAAMBs", "base64");
1290
+ const ICC_SRGB = Buffer.from("AAAMSExpbm8CEAAAbW50clJHQiBYWVogB84AAgAJAAYAMQAAYWNzcE1TRlQAAAAASUVDIHNSR0IAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1IUCAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARY3BydAAAAVAAAAAzZGVzYwAAAYQAAABsd3RwdAAAAfAAAAAUYmtwdAAAAgQAAAAUclhZWgAAAhgAAAAUZ1hZWgAAAiwAAAAUYlhZWgAAAkAAAAAUZG1uZAAAAlQAAABwZG1kZAAAAsQAAABIdnVlZAAAAwwAAABHdmlldwAAA1QAAAAkbHVtaQAAA3gAAAAUbWVhcwAAA4wAAAAkdGVjaAAAA7AAAAAMclRSQwAAA7wAAAgMZ1RSQwAAA7wAAAgMYlRSQwAAA7wAAAgMdGV4dAAAAABDb3B5cmlnaHQgKGMpIDE5OTggSGV3bGV0dC1QYWNrYXJkIENvbXBhbnkAAGRlc2MAAAAAAAAAEnNSR0IgSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAASc1JHQiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFhZWiAAAAAAAADzUQABAAAAARbMWFlaIAAAAAAAAAAAAAAAAAAAAABYWVogAAAAAAAAb6IAADj1AAADkFhZWiAAAAAAAABimQAAt4UAABjaWFlaIAAAAAAAACSgAAAPhAAAts9kZXNjAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAABZJRUMgaHR0cDovL3d3dy5pZWMuY2gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZGVzYwAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAuSUVDIDYxOTY2LTIuMSBEZWZhdWx0IFJHQiBjb2xvdXIgc3BhY2UgLSBzUkdCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGRlc2MAAAAAAAAALFJlZmVyZW5jZSBWaWV3aW5nIENvbmRpdGlvbiBpbiBJRUM2MTk2Ni0yLjEAAAAAAAAAAAAAACxSZWZlcmVuY2UgVmlld2luZyBDb25kaXRpb24gaW4gSUVDNjE5NjYtMi4xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB2aWV3AAAAAAATpP4AFF8uABDPFAAD7cwABBMLAANYngAAAAFYWVogAAAAAABMCVYAUAAAAFcf521lYXMAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAJzaWcgAAAAAENSVCBjdXJ2AAAAAAAABAAAAAAFAAoADwAUABkAHgAjACgALQAyADcAOwBAAEUASgBPAFQAWQBeAGMAaABtAHIAdwB8AIEAhgCLAJAAlQCaAJ8ApACpAK4AsgC3ALwAwQDGAMsA0QDWANwA4QDnAO0A8wD5AP8BBQELAREBFwEdASMBKQEvATUBOwFCAUgBTgFUAVsBYQFoAW4BdQF8AYMBigGRAZgBnwGnAa4BtgG9AcUBzQHVAd0B5QHtAfYB/gIHAg8CGAIhAioCMwI8AkUCTgJYAmECawJ1An8CiQKTAp0CpwKxArsCxgLQAtsC5QLwAvsDBgMRAx0DKAM0A0ADTANYA2UDcQN+A4oDlwOkA7EDvgPLA9kD5gP0BAIEEAQeBCwEOgRJBFcEZgR0BIMEkgShBLAEwATQBN8E7wT/BQ8FHwUwBUAFUQVhBXIFgwWVBaUFtgXIBdkF6wX8Bg4GIAYyBkQGVgZpBnsGjgahBrMGxgbaBu0HAAccBzAHRAdYB2wHgAeUB6oHvgfSB+cH/AgRCCYIPAhRCGcIfQiTCKkIvwjWCOwJAwkZCTAJRwleCXUJjAmkCbsJ0wnrCgIKGgoyCoqKoQq5CtIK6gsCCxsLNAtNC2YLfwuYC7ILywvlC/8MGQwzDE0MZwyCDJwMtwzRDOwNBw0iDT0NWQ10DY8Nqw3GDeIOAA==", "base64");
1291
+ /** Build a chart PDF with font, ExtGState, and ICC color space resources.
1292
+ * Binary concat to embed ICC profiles for correct WebKit color rendering. */
1293
+ function buildChartPdf(w, h, stream) {
1294
+ const parts = [];
1295
+ const offsets = [];
1296
+ let pos = 0;
1297
+ const mark = () => { offsets.push(pos); };
1298
+ const emit = (s) => { const b = Buffer.from(s, "latin1"); parts.push(b); pos += b.length; };
1299
+ const emitBin = (b) => { parts.push(b); pos += b.length; };
1300
+ emit("%PDF-1.4\n");
1301
+ mark();
1302
+ emit(`1 0 obj\n<< /Type /Catalog /Pages 2 0 R /Version /1.4 >>\nendobj\n`);
1303
+ mark();
1304
+ emit(`2 0 obj\n<< /Type /Pages /MediaBox [0 0 ${w} ${h}] /Count 1 /Kids [ 3 0 R ] >>\nendobj\n`);
1305
+ mark();
1306
+ emit(`3 0 obj\n<< /Type /Page /Parent 2 0 R /Resources 4 0 R /Contents 5 0 R /MediaBox [0 0 ${w} ${h}] >>\nendobj\n`);
1307
+ mark();
1308
+ emit([
1309
+ `4 0 obj\n<< /ProcSet [ /PDF /Text ]`,
1310
+ ` /ColorSpace << /Cs1 11 0 R /Cs2 12 0 R >>`,
1311
+ ` /Font << /F1 6 0 R /F2 7 0 R /TT1 7 0 R >>`,
1312
+ ` /ExtGState << /Gs1 8 0 R /Gs2 9 0 R /Gs3 10 0 R >>`,
1313
+ ` >>\nendobj\n`,
1314
+ ].join(""));
1315
+ const streamBuf = Buffer.from(stream, "ascii");
1316
+ mark();
1317
+ emit(`5 0 obj\n<< /Length ${streamBuf.length} >>\nstream\n`);
1318
+ emitBin(streamBuf);
1319
+ emit(`\nendstream\nendobj\n`);
1320
+ mark();
1321
+ emit(`6 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n`);
1322
+ mark();
1323
+ emit(`7 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>\nendobj\n`);
1324
+ mark();
1325
+ emit(`8 0 obj\n<< /Type /ExtGState /ca 0 >>\nendobj\n`);
1326
+ mark();
1327
+ emit(`9 0 obj\n<< /Type /ExtGState /CA 0 >>\nendobj\n`);
1328
+ mark();
1329
+ emit(`10 0 obj\n<< /Type /ExtGState /CA 0.25 >>\nendobj\n`);
1330
+ mark();
1331
+ emit(`11 0 obj\n[ /ICCBased 13 0 R ]\nendobj\n`);
1332
+ mark();
1333
+ emit(`12 0 obj\n[ /ICCBased 14 0 R ]\nendobj\n`);
1334
+ mark();
1335
+ emit(`13 0 obj\n<< /N 3 /Alternate /DeviceRGB /Length ${ICC_GENERIC_RGB.length} >>\nstream\n`);
1336
+ emitBin(ICC_GENERIC_RGB);
1337
+ emit(`\nendstream\nendobj\n`);
1338
+ mark();
1339
+ emit(`14 0 obj\n<< /N 3 /Alternate /DeviceRGB /Length ${ICC_SRGB.length} >>\nstream\n`);
1340
+ emitBin(ICC_SRGB);
1341
+ emit(`\nendstream\nendobj\n`);
1342
+ const xrefOffset = pos;
1343
+ const nObjs = offsets.length + 1;
1344
+ emit(`xref\n0 ${nObjs}\n0000000000 65535 f \n`);
1345
+ for (const off of offsets)
1346
+ emit(`${String(off).padStart(10, "0")} 00000 n \n`);
1347
+ emit(`trailer\n<< /Size ${nObjs} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`);
1348
+ return Buffer.concat(parts);
1349
+ }
1350
+ /**
1351
+ * Resolve table style from tableStyleId + accent color scheme.
1352
+ * OfficeImport's table styles are accent-based with fixed tint formulas.
1353
+ * The most common styles (Medium Style 2, etc.) use accent1 with tinted bands.
1354
+ */
1355
+ function resolveTableStyle(tableData, ctx) {
1356
+ if (!tableData.tableStyleId)
1357
+ return undefined;
1358
+ const props = tableData.properties;
1359
+ if (!props?.firstRow && !props?.bandRow)
1360
+ return undefined;
1361
+ // Resolve accent1 from color scheme
1362
+ const accent1 = ctx.colorScheme?.colors?.accent1;
1363
+ if (!accent1)
1364
+ return undefined;
1365
+ const resolved = resolveColor(accent1, ctx.colorMap, ctx.colorScheme);
1366
+ const accentHex = `#${hex2(resolved.r)}${hex2(resolved.g)}${hex2(resolved.b)}`;
1367
+ // Compute banded row tints (OfficeImport applies fixed tint levels to the accent color)
1368
+ // Medium Style 2: odd rows ~40% tint, even rows ~20% tint
1369
+ const oddBand = tintColor(resolved, 0.4);
1370
+ const evenBand = tintColor(resolved, 0.2);
1371
+ return {
1372
+ accentColor: accentHex,
1373
+ firstRow: props.firstRow ?? false,
1374
+ bandRow: props.bandRow ?? false,
1375
+ bandFills: [oddBand, evenBand],
1376
+ headerBorderWidth: "3",
1377
+ borderColor: "#ffffff",
1378
+ };
1379
+ }
1380
+ /** Apply table style defaults for a specific cell position. */
1381
+ function getCellStyleDefaults(style, rowIdx, totalRows) {
1382
+ // Header row (row 0 when firstRow=true)
1383
+ if (style.firstRow && rowIdx === 0) {
1384
+ return {
1385
+ fill: style.accentColor,
1386
+ borderWidth: style.headerBorderWidth,
1387
+ borderColor: style.borderColor,
1388
+ };
1389
+ }
1390
+ // Banded rows
1391
+ if (style.bandRow) {
1392
+ const dataRowIdx = style.firstRow ? rowIdx - 1 : rowIdx;
1393
+ if (dataRowIdx >= 0) {
1394
+ const isOdd = dataRowIdx % 2 === 0; // first data row is "odd"
1395
+ return {
1396
+ fill: isOdd ? style.bandFills[0] : style.bandFills[1],
1397
+ borderWidth: "thin",
1398
+ borderColor: style.borderColor,
1399
+ };
1400
+ }
1401
+ }
1402
+ return undefined;
1403
+ }
1404
+ /** Tint a color toward white by the given amount (0=white, 1=original). */
1405
+ function tintColor(c, amount) {
1406
+ const r = Math.round(c.r + (255 - c.r) * (1 - amount));
1407
+ const g = Math.round(c.g + (255 - c.g) * (1 - amount));
1408
+ const b = Math.round(c.b + (255 - c.b) * (1 - amount));
1409
+ return `#${hex2(r)}${hex2(g)}${hex2(b)}`;
1410
+ }
1411
+ /** Render a GraphicFrame (chart/SmartArt) using its pre-rendered fallback image. */
1412
+ function mapGraphicFrameFallbackImage(gf, b, ctx, attachments) {
1413
+ // Fallback image data is pre-resolved in html-generator and stored on the GraphicFrame
1414
+ const data = gf.fallbackImageData;
1415
+ if (!data || !attachments)
1416
+ return "";
1417
+ const x = emuToPx(b.x);
1418
+ const y = emuToPx(b.y);
1419
+ const w = emuToPx(b.cx);
1420
+ const h = emuToPx(b.cy);
1421
+ const ext = detectImageExt(data);
1422
+ const idx = nextAttachmentIndex();
1423
+ const name = `Attachment${idx}.${ext}`;
1424
+ attachments.set(name, data);
1425
+ return `<img src="${name}" style="position:absolute; top:${y}; left:${x}; width:${w}; height:${h};">`;
1426
+ }
1427
+ function detectImageExt(buf) {
1428
+ if (buf[0] === 0x89 && buf[1] === 0x50)
1429
+ return "png";
1430
+ if (buf[0] === 0xFF && buf[1] === 0xD8)
1431
+ return "jpeg";
1432
+ if (buf[0] === 0x47 && buf[1] === 0x49)
1433
+ return "gif";
1434
+ if (buf.length > 4 && buf.slice(0, 4).toString("ascii") === "RIFF")
1435
+ return "webp";
1436
+ // EMF/WMF headers
1437
+ if (buf.length > 44 && buf[0] === 0x01 && buf[1] === 0x00 && buf[2] === 0x00 && buf[3] === 0x00)
1438
+ return "emf";
1439
+ return "png";
1440
+ }
1441
+ function fillToColor(fill, ctx) {
1442
+ if (!fill)
1443
+ return null;
1444
+ if (fill.type === "solid")
1445
+ return rgbaToHex(resolveColor(fill.color, ctx.colorMap, ctx.colorScheme));
1446
+ if (fill.type === "noFill")
1447
+ return null;
1448
+ return null;
1449
+ }
1450
+ function strokeToColor(stroke, ctx) {
1451
+ if (!stroke?.fill)
1452
+ return null;
1453
+ if (stroke.fill.type === "solid")
1454
+ return rgbaToHex(resolveColor(stroke.fill.color, ctx.colorMap, ctx.colorScheme));
1455
+ return null;
1456
+ }
1457
+ function rgbaToHex(c) {
1458
+ if (c.a < 1)
1459
+ return `rgba(${c.r},${c.g},${c.b},${c.a.toFixed(2)})`;
1460
+ return `#${hex2(c.r)}${hex2(c.g)}${hex2(c.b)}`;
1461
+ }
1462
+ function hex2(n) {
1463
+ return n.toString(16).padStart(2, "0");
1464
+ }