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.
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +175 -0
- package/dist/diff/compare.d.ts +17 -0
- package/dist/diff/compare.js +71 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +72 -0
- package/dist/lint.d.ts +27 -0
- package/dist/lint.js +328 -0
- package/dist/mapper/bleed-map.d.ts +6 -0
- package/dist/mapper/bleed-map.js +1 -0
- package/dist/mapper/constants.d.ts +2 -0
- package/dist/mapper/constants.js +4 -0
- package/dist/mapper/drawable-mapper.d.ts +16 -0
- package/dist/mapper/drawable-mapper.js +1464 -0
- package/dist/mapper/html-generator.d.ts +13 -0
- package/dist/mapper/html-generator.js +539 -0
- package/dist/mapper/image-mapper.d.ts +14 -0
- package/dist/mapper/image-mapper.js +70 -0
- package/dist/mapper/nano-malloc.d.ts +130 -0
- package/dist/mapper/nano-malloc.js +197 -0
- package/dist/mapper/ql-bleed.d.ts +35 -0
- package/dist/mapper/ql-bleed.js +254 -0
- package/dist/mapper/shape-mapper.d.ts +41 -0
- package/dist/mapper/shape-mapper.js +2384 -0
- package/dist/mapper/slide-mapper.d.ts +4 -0
- package/dist/mapper/slide-mapper.js +112 -0
- package/dist/mapper/style-builder.d.ts +12 -0
- package/dist/mapper/style-builder.js +30 -0
- package/dist/mapper/text-mapper.d.ts +14 -0
- package/dist/mapper/text-mapper.js +302 -0
- package/dist/model/enums.d.ts +25 -0
- package/dist/model/enums.js +2 -0
- package/dist/model/types.d.ts +482 -0
- package/dist/model/types.js +7 -0
- package/dist/package/content-types.d.ts +1 -0
- package/dist/package/content-types.js +4 -0
- package/dist/package/package.d.ts +10 -0
- package/dist/package/package.js +52 -0
- package/dist/package/relationships.d.ts +6 -0
- package/dist/package/relationships.js +25 -0
- package/dist/package/zip.d.ts +6 -0
- package/dist/package/zip.js +17 -0
- package/dist/reader/color.d.ts +3 -0
- package/dist/reader/color.js +79 -0
- package/dist/reader/drawing.d.ts +17 -0
- package/dist/reader/drawing.js +403 -0
- package/dist/reader/effects.d.ts +2 -0
- package/dist/reader/effects.js +83 -0
- package/dist/reader/fill.d.ts +2 -0
- package/dist/reader/fill.js +94 -0
- package/dist/reader/presentation.d.ts +5 -0
- package/dist/reader/presentation.js +127 -0
- package/dist/reader/slide-layout.d.ts +2 -0
- package/dist/reader/slide-layout.js +28 -0
- package/dist/reader/slide-master.d.ts +4 -0
- package/dist/reader/slide-master.js +49 -0
- package/dist/reader/slide.d.ts +2 -0
- package/dist/reader/slide.js +26 -0
- package/dist/reader/text-list-style.d.ts +2 -0
- package/dist/reader/text-list-style.js +9 -0
- package/dist/reader/text.d.ts +5 -0
- package/dist/reader/text.js +295 -0
- package/dist/reader/theme.d.ts +2 -0
- package/dist/reader/theme.js +109 -0
- package/dist/reader/transform.d.ts +2 -0
- package/dist/reader/transform.js +21 -0
- package/dist/render/image-renderer.d.ts +3 -0
- package/dist/render/image-renderer.js +33 -0
- package/dist/render/renderer.d.ts +9 -0
- package/dist/render/renderer.js +178 -0
- package/dist/render/shape-renderer.d.ts +3 -0
- package/dist/render/shape-renderer.js +175 -0
- package/dist/render/text-renderer.d.ts +3 -0
- package/dist/render/text-renderer.js +152 -0
- package/dist/resolve/color-resolver.d.ts +18 -0
- package/dist/resolve/color-resolver.js +321 -0
- package/dist/resolve/font-map.d.ts +2 -0
- package/dist/resolve/font-map.js +66 -0
- package/dist/resolve/inheritance.d.ts +5 -0
- package/dist/resolve/inheritance.js +106 -0
- 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,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,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
|
+
};
|