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,21 @@
1
+ export function readTransform(xfrm) {
2
+ if (xfrm == null)
3
+ return undefined;
4
+ const off = xfrm.off;
5
+ const ext = xfrm.ext;
6
+ if (off == null || ext == null)
7
+ return undefined;
8
+ const bounds = {
9
+ x: Number(off["@_x"] ?? 0),
10
+ y: Number(off["@_y"] ?? 0),
11
+ cx: Number(ext["@_cx"] ?? 0),
12
+ cy: Number(ext["@_cy"] ?? 0),
13
+ };
14
+ if (xfrm["@_rot"] != null)
15
+ bounds.rot = Number(xfrm["@_rot"]);
16
+ if (xfrm["@_flipH"] === "1" || xfrm["@_flipH"] === "true")
17
+ bounds.flipH = true;
18
+ if (xfrm["@_flipV"] === "1" || xfrm["@_flipV"] === "true")
19
+ bounds.flipV = true;
20
+ return bounds;
21
+ }
@@ -0,0 +1,3 @@
1
+ import type { SKRSContext2D } from "@napi-rs/canvas";
2
+ import type { Picture } from "../model/types.js";
3
+ export declare function drawPicture(ctx: SKRSContext2D, pic: Picture, emuToPx: number, imageData?: Buffer | null): Promise<void>;
@@ -0,0 +1,33 @@
1
+ import { loadImage } from "@napi-rs/canvas";
2
+ const EMU_PER_INCH = 914400;
3
+ export async function drawPicture(ctx, pic, emuToPx, imageData) {
4
+ const b = pic.bounds;
5
+ if (!b)
6
+ return;
7
+ const data = imageData ?? pic.blipData;
8
+ if (!data)
9
+ return;
10
+ const x = b.x * emuToPx;
11
+ const y = b.y * emuToPx;
12
+ const w = b.cx * emuToPx;
13
+ const h = b.cy * emuToPx;
14
+ const img = await loadImage(data);
15
+ ctx.save();
16
+ if (b.rot) {
17
+ const cx = x + w / 2;
18
+ const cy = y + h / 2;
19
+ ctx.translate(cx, cy);
20
+ ctx.rotate((b.rot / 60000) * (Math.PI / 180));
21
+ ctx.translate(-cx, -cy);
22
+ }
23
+ if (b.flipH || b.flipV) {
24
+ ctx.translate(b.flipH ? x + w : 0, b.flipV ? y + h : 0);
25
+ ctx.scale(b.flipH ? -1 : 1, b.flipV ? -1 : 1);
26
+ if (b.flipH)
27
+ ctx.translate(-x, 0);
28
+ if (b.flipV)
29
+ ctx.translate(0, -y);
30
+ }
31
+ ctx.drawImage(img, x, y, w, h);
32
+ ctx.restore();
33
+ }
@@ -0,0 +1,9 @@
1
+ import type { Slide, Presentation } from "../model/types.js";
2
+ import type { PptxPackage } from "../package/package.js";
3
+ export interface RenderOptions {
4
+ width?: number;
5
+ scale?: number;
6
+ pkg?: PptxPackage;
7
+ slideIndex?: number;
8
+ }
9
+ export declare function renderSlide(slide: Slide, presentation: Presentation, options?: RenderOptions): Promise<Buffer>;
@@ -0,0 +1,178 @@
1
+ import { createCanvas } from "@napi-rs/canvas";
2
+ import { resolveColor } from "../resolve/color-resolver.js";
3
+ import { drawShape } from "./shape-renderer.js";
4
+ import { drawTextBody } from "./text-renderer.js";
5
+ import { drawPicture } from "./image-renderer.js";
6
+ export async function renderSlide(slide, presentation, options) {
7
+ const baseWidth = options?.width ?? 1920;
8
+ const scale = options?.scale ?? 2;
9
+ const { cx: slideCx, cy: slideCy } = presentation.slideSize;
10
+ const canvasW = baseWidth * scale;
11
+ const canvasH = Math.round(canvasW * (slideCy / slideCx));
12
+ const emuToPx = canvasW / slideCx;
13
+ const canvas = createCanvas(canvasW, canvasH);
14
+ const ctx = canvas.getContext("2d");
15
+ // Resolve theme context from slide hierarchy
16
+ const master = slide.slideLayout.slideMaster;
17
+ const colorMap = slide.colorMapOverride
18
+ ?? slide.slideLayout.colorMapOverride
19
+ ?? master.colorMap;
20
+ const colorScheme = master.theme.colorScheme;
21
+ const fontScheme = master.theme.fontScheme;
22
+ // Background
23
+ const bg = resolveBackground(slide, colorMap, colorScheme);
24
+ ctx.fillStyle = bg;
25
+ ctx.fillRect(0, 0, canvasW, canvasH);
26
+ // Resolve image data for this slide
27
+ const imageMap = new Map();
28
+ if (options?.pkg && options?.slideIndex != null) {
29
+ const slidePath = `ppt/slides/slide${options.slideIndex + 1}.xml`;
30
+ try {
31
+ const slideRels = await options.pkg.getRelationships(slidePath);
32
+ for (const [rId, rel] of slideRels) {
33
+ if (rel.type === "image") {
34
+ const imgPath = options.pkg.resolveRelTarget(slidePath, rel.target);
35
+ const buf = await options.pkg.getPartBuffer(imgPath);
36
+ if (buf)
37
+ imageMap.set(rId, buf);
38
+ }
39
+ }
40
+ }
41
+ catch { /* slide rels might not exist */ }
42
+ }
43
+ // Render drawables in z-order (array order)
44
+ for (const drawable of slide.drawables) {
45
+ await drawDrawable(ctx, drawable, emuToPx, colorMap, colorScheme, fontScheme, imageMap);
46
+ }
47
+ return canvas.toBuffer("image/png");
48
+ }
49
+ // ── Drawable dispatch ───────────────────────────────────────────────
50
+ async function drawDrawable(ctx, drawable, emuToPx, colorMap, colorScheme, fontScheme, imageMap) {
51
+ if (drawable.hidden)
52
+ return;
53
+ switch (drawable.drawableType) {
54
+ case "sp":
55
+ drawShapeWithText(ctx, drawable, emuToPx, colorMap, colorScheme, fontScheme);
56
+ break;
57
+ case "pic": {
58
+ const pic = drawable;
59
+ const imgData = pic.blipRId ? imageMap?.get(pic.blipRId) : undefined;
60
+ await drawPicture(ctx, pic, emuToPx, imgData);
61
+ break;
62
+ }
63
+ case "grpSp":
64
+ await drawGroup(ctx, drawable, emuToPx, colorMap, colorScheme, fontScheme, imageMap);
65
+ break;
66
+ case "cxnSp":
67
+ drawConnector(ctx, drawable, emuToPx, colorMap, colorScheme);
68
+ break;
69
+ // graphicFrame (tables/charts) not yet rendered
70
+ }
71
+ }
72
+ // ── Shape + text ────────────────────────────────────────────────────
73
+ function drawShapeWithText(ctx, shape, emuToPx, colorMap, colorScheme, fontScheme) {
74
+ drawShape(ctx, shape, emuToPx, colorMap, colorScheme);
75
+ if (shape.textBody && shape.bounds) {
76
+ drawTextBody(ctx, shape.textBody, shape.bounds, emuToPx, colorMap, colorScheme, fontScheme);
77
+ }
78
+ }
79
+ // ── Group ───────────────────────────────────────────────────────────
80
+ async function drawGroup(ctx, group, emuToPx, colorMap, colorScheme, fontScheme, imageMap) {
81
+ const b = group.bounds;
82
+ const cb = group.childBounds;
83
+ if (!b || !cb) {
84
+ for (const child of group.children) {
85
+ await drawDrawable(ctx, child, emuToPx, colorMap, colorScheme, fontScheme, imageMap);
86
+ }
87
+ return;
88
+ }
89
+ ctx.save();
90
+ // Translate to group position, then scale child coordinate space to group bounds
91
+ const gx = b.x * emuToPx;
92
+ const gy = b.y * emuToPx;
93
+ const gw = b.cx * emuToPx;
94
+ const gh = b.cy * emuToPx;
95
+ const cx = cb.x * emuToPx;
96
+ const cy = cb.y * emuToPx;
97
+ const cw = cb.cx * emuToPx;
98
+ const ch = cb.cy * emuToPx;
99
+ if (b.rot) {
100
+ const centerX = gx + gw / 2;
101
+ const centerY = gy + gh / 2;
102
+ ctx.translate(centerX, centerY);
103
+ ctx.rotate((b.rot / 60000) * (Math.PI / 180));
104
+ ctx.translate(-centerX, -centerY);
105
+ }
106
+ ctx.translate(gx, gy);
107
+ if (cw > 0 && ch > 0)
108
+ ctx.scale(gw / cw, gh / ch);
109
+ ctx.translate(-cx, -cy);
110
+ for (const child of group.children) {
111
+ await drawDrawable(ctx, child, emuToPx, colorMap, colorScheme, fontScheme, imageMap);
112
+ }
113
+ ctx.restore();
114
+ }
115
+ // ── Connector ───────────────────────────────────────────────────────
116
+ function drawConnector(ctx, conn, emuToPx, colorMap, colorScheme) {
117
+ const b = conn.bounds;
118
+ if (!b)
119
+ return;
120
+ const x = b.x * emuToPx;
121
+ const y = b.y * emuToPx;
122
+ const w = b.cx * emuToPx;
123
+ const h = b.cy * emuToPx;
124
+ ctx.save();
125
+ if (b.rot) {
126
+ const cx = x + w / 2;
127
+ const cy = y + h / 2;
128
+ ctx.translate(cx, cy);
129
+ ctx.rotate((b.rot / 60000) * (Math.PI / 180));
130
+ ctx.translate(-cx, -cy);
131
+ }
132
+ const stroke = conn.stroke;
133
+ if (stroke?.width) {
134
+ ctx.lineWidth = stroke.width * emuToPx;
135
+ if (stroke.fill?.type === "solid") {
136
+ ctx.strokeStyle = rgbaStr(resolveColor(stroke.fill.color, colorMap, colorScheme));
137
+ }
138
+ else {
139
+ ctx.strokeStyle = "rgba(0,0,0,1)";
140
+ }
141
+ }
142
+ else {
143
+ ctx.lineWidth = 1;
144
+ ctx.strokeStyle = "rgba(0,0,0,1)";
145
+ }
146
+ ctx.beginPath();
147
+ ctx.moveTo(x, y);
148
+ ctx.lineTo(x + w, y + h);
149
+ ctx.stroke();
150
+ ctx.restore();
151
+ }
152
+ // ── Background resolution ───────────────────────────────────────────
153
+ function resolveBackground(slide, colorMap, colorScheme) {
154
+ // Check slide -> layout -> master for background fill
155
+ const bg = slide.background
156
+ ?? slide.slideLayout.background
157
+ ?? slide.slideLayout.slideMaster.background;
158
+ if (bg?.fill) {
159
+ const color = fillToRgba(bg.fill, colorMap, colorScheme);
160
+ if (color)
161
+ return rgbaStr(color);
162
+ }
163
+ return "rgba(255,255,255,1)";
164
+ }
165
+ function fillToRgba(fill, colorMap, colorScheme) {
166
+ if (fill.type === "solid")
167
+ return resolveColor(fill.color, colorMap, colorScheme);
168
+ if (fill.type === "gradient" && fill.stops.length > 0) {
169
+ return resolveColor(fill.stops[0].color, colorMap, colorScheme);
170
+ }
171
+ if (fill.type === "noFill")
172
+ return null;
173
+ return null;
174
+ }
175
+ // ── Helpers ─────────────────────────────────────────────────────────
176
+ function rgbaStr(c) {
177
+ return `rgba(${c.r},${c.g},${c.b},${c.a.toFixed(3)})`;
178
+ }
@@ -0,0 +1,3 @@
1
+ import type { SKRSContext2D } from "@napi-rs/canvas";
2
+ import type { Shape, ColorMap, ColorScheme } from "../model/types.js";
3
+ export declare function drawShape(ctx: SKRSContext2D, shape: Shape, emuToPx: number, colorMap: ColorMap, colorScheme: ColorScheme): void;
@@ -0,0 +1,175 @@
1
+ import { resolveColor } from "../resolve/color-resolver.js";
2
+ const EMU_PER_INCH = 914400;
3
+ export function drawShape(ctx, shape, emuToPx, colorMap, colorScheme) {
4
+ const b = shape.bounds;
5
+ if (!b)
6
+ return;
7
+ const x = b.x * emuToPx;
8
+ const y = b.y * emuToPx;
9
+ const w = b.cx * emuToPx;
10
+ const h = b.cy * emuToPx;
11
+ ctx.save();
12
+ // Rotation
13
+ if (b.rot) {
14
+ const cx = x + w / 2;
15
+ const cy = y + h / 2;
16
+ ctx.translate(cx, cy);
17
+ ctx.rotate((b.rot / 60000) * (Math.PI / 180));
18
+ ctx.translate(-cx, -cy);
19
+ }
20
+ // Flips
21
+ if (b.flipH || b.flipV) {
22
+ ctx.translate(b.flipH ? x + w : 0, b.flipV ? y + h : 0);
23
+ ctx.scale(b.flipH ? -1 : 1, b.flipV ? -1 : 1);
24
+ if (b.flipH)
25
+ ctx.translate(-x, 0);
26
+ if (b.flipV)
27
+ ctx.translate(0, -y);
28
+ }
29
+ // Shadow (must be set before fill/stroke draw calls)
30
+ const shadow = shape.effects?.find((e) => e.type === "outerShdw");
31
+ if (shadow)
32
+ applyShadow(ctx, shadow, emuToPx, colorMap, colorScheme);
33
+ // Fill
34
+ const fillColor = resolveFill(shape.fill, colorMap, colorScheme);
35
+ if (fillColor)
36
+ ctx.fillStyle = rgbaStr(fillColor);
37
+ // Stroke
38
+ const stroke = shape.stroke;
39
+ const hasStroke = stroke?.width && stroke.width > 0;
40
+ if (hasStroke) {
41
+ ctx.lineWidth = stroke.width * emuToPx;
42
+ const strokeColor = resolveStrokeFill(stroke, colorMap, colorScheme);
43
+ ctx.strokeStyle = rgbaStr(strokeColor);
44
+ }
45
+ // Draw geometry
46
+ const preset = shape.geometry?.preset ?? "rect";
47
+ drawGeometry(ctx, preset, x, y, w, h, shape.geometry?.adjustValues, fillColor != null, hasStroke === true);
48
+ // Reset shadow
49
+ if (shadow) {
50
+ ctx.shadowBlur = 0;
51
+ ctx.shadowOffsetX = 0;
52
+ ctx.shadowOffsetY = 0;
53
+ ctx.shadowColor = "transparent";
54
+ }
55
+ ctx.restore();
56
+ }
57
+ // ── Geometry ────────────────────────────────────────────────────────
58
+ function drawGeometry(ctx, preset, x, y, w, h, adjustValues, doFill, doStroke) {
59
+ switch (preset) {
60
+ case "ellipse": {
61
+ ctx.beginPath();
62
+ ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
63
+ if (doFill)
64
+ ctx.fill();
65
+ if (doStroke)
66
+ ctx.stroke();
67
+ break;
68
+ }
69
+ case "roundRect": {
70
+ const adj = parseAdj(adjustValues, "adj", 16667);
71
+ const radius = (adj / 100000) * Math.min(w, h);
72
+ drawRoundRect(ctx, x, y, w, h, radius);
73
+ if (doFill)
74
+ ctx.fill();
75
+ if (doStroke)
76
+ ctx.stroke();
77
+ break;
78
+ }
79
+ case "line":
80
+ case "straightConnector1": {
81
+ ctx.beginPath();
82
+ ctx.moveTo(x, y);
83
+ ctx.lineTo(x + w, y + h);
84
+ if (doStroke)
85
+ ctx.stroke();
86
+ break;
87
+ }
88
+ case "triangle": {
89
+ ctx.beginPath();
90
+ ctx.moveTo(x + w / 2, y);
91
+ ctx.lineTo(x + w, y + h);
92
+ ctx.lineTo(x, y + h);
93
+ ctx.closePath();
94
+ if (doFill)
95
+ ctx.fill();
96
+ if (doStroke)
97
+ ctx.stroke();
98
+ break;
99
+ }
100
+ case "diamond": {
101
+ ctx.beginPath();
102
+ ctx.moveTo(x + w / 2, y);
103
+ ctx.lineTo(x + w, y + h / 2);
104
+ ctx.lineTo(x + w / 2, y + h);
105
+ ctx.lineTo(x, y + h / 2);
106
+ ctx.closePath();
107
+ if (doFill)
108
+ ctx.fill();
109
+ if (doStroke)
110
+ ctx.stroke();
111
+ break;
112
+ }
113
+ default: {
114
+ // Fallback: rectangle
115
+ if (doFill)
116
+ ctx.fillRect(x, y, w, h);
117
+ if (doStroke)
118
+ ctx.strokeRect(x, y, w, h);
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ function drawRoundRect(ctx, x, y, w, h, r) {
124
+ r = Math.min(r, w / 2, h / 2);
125
+ ctx.beginPath();
126
+ ctx.moveTo(x + r, y);
127
+ ctx.arcTo(x + w, y, x + w, y + h, r);
128
+ ctx.arcTo(x + w, y + h, x, y + h, r);
129
+ ctx.arcTo(x, y + h, x, y, r);
130
+ ctx.arcTo(x, y, x + w, y, r);
131
+ ctx.closePath();
132
+ }
133
+ function parseAdj(adjustValues, name, defaultVal) {
134
+ if (!adjustValues?.[name])
135
+ return defaultVal;
136
+ const raw = adjustValues[name];
137
+ // Adjustment formulas can be "val N" or just a number
138
+ const match = raw.match(/\d+/);
139
+ return match ? Number(match[0]) : defaultVal;
140
+ }
141
+ // ── Fill/stroke resolution ──────────────────────────────────────────
142
+ function resolveFill(fill, colorMap, colorScheme) {
143
+ if (!fill)
144
+ return null;
145
+ if (fill.type === "noFill")
146
+ return null;
147
+ if (fill.type === "solid")
148
+ return resolveColor(fill.color, colorMap, colorScheme);
149
+ if (fill.type === "gradient" && fill.stops.length > 0) {
150
+ // Approximate gradient with first stop color
151
+ return resolveColor(fill.stops[0].color, colorMap, colorScheme);
152
+ }
153
+ return null;
154
+ }
155
+ function resolveStrokeFill(stroke, colorMap, colorScheme) {
156
+ if (stroke.fill?.type === "solid")
157
+ return resolveColor(stroke.fill.color, colorMap, colorScheme);
158
+ return { r: 0, g: 0, b: 0, a: 1 };
159
+ }
160
+ // ── Shadow ──────────────────────────────────────────────────────────
161
+ function applyShadow(ctx, shadow, emuToPx, colorMap, colorScheme) {
162
+ const blur = (shadow.blurRad ?? 0) * emuToPx;
163
+ const dist = (shadow.dist ?? 0) * emuToPx;
164
+ const dirRad = ((shadow.dir ?? 0) / 60000) * (Math.PI / 180);
165
+ ctx.shadowBlur = blur;
166
+ ctx.shadowOffsetX = Math.cos(dirRad) * dist;
167
+ ctx.shadowOffsetY = Math.sin(dirRad) * dist;
168
+ ctx.shadowColor = shadow.color
169
+ ? rgbaStr(resolveColor(shadow.color, colorMap, colorScheme))
170
+ : "rgba(0,0,0,0.4)";
171
+ }
172
+ // ── Helpers ─────────────────────────────────────────────────────────
173
+ function rgbaStr(c) {
174
+ return `rgba(${c.r},${c.g},${c.b},${c.a.toFixed(3)})`;
175
+ }
@@ -0,0 +1,3 @@
1
+ import type { SKRSContext2D } from "@napi-rs/canvas";
2
+ import type { TextBody, OrientedBounds, ColorMap, ColorScheme, FontScheme } from "../model/types.js";
3
+ export declare function drawTextBody(ctx: SKRSContext2D, textBody: TextBody, bounds: OrientedBounds, emuToPx: number, colorMap: ColorMap, colorScheme: ColorScheme, fontScheme?: FontScheme): void;
@@ -0,0 +1,152 @@
1
+ import { resolveColor } from "../resolve/color-resolver.js";
2
+ import { resolveFontFamily } from "../resolve/font-map.js";
3
+ const EMU_PER_PT = 12700;
4
+ const DEFAULT_FONT_SIZE = 1800; // 18pt in hundredths
5
+ const DEFAULT_LINE_HEIGHT_FACTOR = 1.2;
6
+ // Default text body insets (EMU)
7
+ const DEFAULT_L_INS = 91440;
8
+ const DEFAULT_R_INS = 91440;
9
+ const DEFAULT_T_INS = 45720;
10
+ const DEFAULT_B_INS = 45720;
11
+ export function drawTextBody(ctx, textBody, bounds, emuToPx, colorMap, colorScheme, fontScheme) {
12
+ const bp = textBody.properties;
13
+ // Text box in pixels
14
+ const lIns = (bp?.lIns ?? DEFAULT_L_INS) * emuToPx;
15
+ const rIns = (bp?.rIns ?? DEFAULT_R_INS) * emuToPx;
16
+ const tIns = (bp?.tIns ?? DEFAULT_T_INS) * emuToPx;
17
+ const bIns = (bp?.bIns ?? DEFAULT_B_INS) * emuToPx;
18
+ const boxX = bounds.x * emuToPx + lIns;
19
+ const boxY = bounds.y * emuToPx + tIns;
20
+ const boxW = bounds.cx * emuToPx - lIns - rIns;
21
+ const boxH = bounds.cy * emuToPx - tIns - bIns;
22
+ if (boxW <= 0 || boxH <= 0)
23
+ return;
24
+ // Measure total content height for vertical anchor
25
+ const lines = layoutParagraphs(ctx, textBody.paragraphs, boxW, emuToPx, colorMap, colorScheme, fontScheme);
26
+ const totalHeight = lines.reduce((sum, l) => sum + l.lineHeight, 0);
27
+ // Vertical anchor offset
28
+ const anchor = bp?.anchor ?? "t";
29
+ let yOffset = 0;
30
+ if (anchor === "ctr")
31
+ yOffset = Math.max(0, (boxH - totalHeight) / 2);
32
+ else if (anchor === "b")
33
+ yOffset = Math.max(0, boxH - totalHeight);
34
+ // Draw lines
35
+ ctx.save();
36
+ ctx.rect(boxX - 0.5, boxY - 0.5, boxW + 1, boxH + 1);
37
+ ctx.clip();
38
+ let curY = boxY + yOffset;
39
+ for (const line of lines) {
40
+ // Horizontal alignment offset
41
+ const slack = boxW - line.width;
42
+ let lineX = boxX;
43
+ if (line.align === "ctr")
44
+ lineX += slack / 2;
45
+ else if (line.align === "r")
46
+ lineX += slack;
47
+ let curX = lineX;
48
+ for (const seg of line.segments) {
49
+ ctx.font = seg.font;
50
+ ctx.fillStyle = rgbaStr(seg.color);
51
+ ctx.fillText(seg.text, curX, curY + line.ascent);
52
+ curX += seg.width;
53
+ }
54
+ curY += line.lineHeight;
55
+ }
56
+ ctx.restore();
57
+ }
58
+ function layoutParagraphs(ctx, paragraphs, boxW, emuToPx, colorMap, colorScheme, fontScheme) {
59
+ const allLines = [];
60
+ for (const para of paragraphs) {
61
+ const align = para.properties?.align ?? "l";
62
+ const segments = buildSegments(ctx, para.runs, emuToPx, colorMap, colorScheme, fontScheme);
63
+ if (segments.length === 0) {
64
+ // Empty paragraph -- still contributes a line break
65
+ const fontSize = resolveParaFontSize(para, emuToPx);
66
+ allLines.push({ segments: [], width: 0, lineHeight: fontSize * DEFAULT_LINE_HEIGHT_FACTOR, ascent: fontSize * 0.8, align });
67
+ continue;
68
+ }
69
+ // Word-wrap
70
+ const wrapped = wordWrap(segments, boxW);
71
+ for (const line of wrapped) {
72
+ const maxFontSize = Math.max(...line.map(s => s.fontSize), 1);
73
+ const lineHeight = maxFontSize * DEFAULT_LINE_HEIGHT_FACTOR;
74
+ const width = line.reduce((s, seg) => s + seg.width, 0);
75
+ allLines.push({ segments: line, width, lineHeight, ascent: maxFontSize * 0.8, align });
76
+ }
77
+ }
78
+ return allLines;
79
+ }
80
+ function buildSegments(ctx, runs, emuToPx, colorMap, colorScheme, fontScheme) {
81
+ const segments = [];
82
+ for (const run of runs) {
83
+ if (run.type === "br")
84
+ continue; // Line breaks handled by paragraph structure
85
+ const text = run.text;
86
+ if (!text)
87
+ continue;
88
+ const props = run.properties;
89
+ const fontSize = resolveRunFontSize(props, emuToPx);
90
+ const font = buildFontString(props, fontSize, fontScheme);
91
+ const color = resolveRunColor(props?.fill, colorMap, colorScheme);
92
+ ctx.font = font;
93
+ const width = ctx.measureText(text).width;
94
+ segments.push({ text, font, color, width, fontSize });
95
+ }
96
+ return segments;
97
+ }
98
+ function wordWrap(segments, maxWidth) {
99
+ if (segments.length === 0)
100
+ return [[]];
101
+ const lines = [[]];
102
+ let lineWidth = 0;
103
+ for (const seg of segments) {
104
+ // If the whole segment fits, add it directly
105
+ if (lineWidth + seg.width <= maxWidth || lineWidth === 0) {
106
+ lines[lines.length - 1].push(seg);
107
+ lineWidth += seg.width;
108
+ continue;
109
+ }
110
+ // Break at word boundaries within this segment
111
+ const words = seg.text.split(/(?<=\s)/); // split keeping trailing whitespace
112
+ for (const word of words) {
113
+ // We need a fresh width measurement approximation: proportional to char count
114
+ const wordWidth = seg.width * (word.length / seg.text.length);
115
+ if (lineWidth + wordWidth > maxWidth && lineWidth > 0) {
116
+ lines.push([]);
117
+ lineWidth = 0;
118
+ }
119
+ lines[lines.length - 1].push({ ...seg, text: word, width: wordWidth });
120
+ lineWidth += wordWidth;
121
+ }
122
+ }
123
+ return lines;
124
+ }
125
+ // ── Property resolution helpers ─────────────────────────────────────
126
+ function resolveRunFontSize(props, emuToPx) {
127
+ const raw = props?.fontSize ?? DEFAULT_FONT_SIZE;
128
+ // fontSize is in hundredths of a point; convert: pts = raw/100, pixels = pts * EMU_PER_PT * emuToPx
129
+ return (raw / 100) * EMU_PER_PT * emuToPx;
130
+ }
131
+ function resolveParaFontSize(para, emuToPx) {
132
+ // Use the endParaRPr or first run's fontSize, or default
133
+ const raw = para.endParaRPr?.fontSize
134
+ ?? para.runs.find(r => r.properties?.fontSize)?.properties?.fontSize
135
+ ?? DEFAULT_FONT_SIZE;
136
+ return (raw / 100) * EMU_PER_PT * emuToPx;
137
+ }
138
+ function buildFontString(props, pxSize, fontScheme) {
139
+ const bold = props?.bold ? "bold " : "";
140
+ const italic = props?.italic ? "italic " : "";
141
+ const family = resolveFontFamily(props?.latinFont, fontScheme);
142
+ return `${italic}${bold}${pxSize}px "${family}"`;
143
+ }
144
+ function resolveRunColor(fill, colorMap, colorScheme) {
145
+ if (fill?.type === "solid")
146
+ return resolveColor(fill.color, colorMap, colorScheme);
147
+ return { r: 0, g: 0, b: 0, a: 1 }; // default black
148
+ }
149
+ // ── Helpers ─────────────────────────────────────────────────────────
150
+ function rgbaStr(c) {
151
+ return `rgba(${c.r},${c.g},${c.b},${c.a.toFixed(3)})`;
152
+ }
@@ -0,0 +1,18 @@
1
+ import type { Color, ColorMap, ColorScheme } from "../model/types.js";
2
+ export interface RGBA {
3
+ r: number;
4
+ g: number;
5
+ b: number;
6
+ a: number;
7
+ }
8
+ export declare function resolveColor(color: Color, colorMap: ColorMap, colorScheme: ColorScheme): RGBA;
9
+ export declare function rgbToHsl(r: number, g: number, b: number): {
10
+ h: number;
11
+ s: number;
12
+ l: number;
13
+ };
14
+ export declare function hslToRgb(h: number, s: number, l: number): {
15
+ r: number;
16
+ g: number;
17
+ b: number;
18
+ };