abstract-document 19.1.25 → 19.2.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.
@@ -3,180 +3,350 @@ import { ImageRun, IMediaTransformation } from "docx";
3
3
  import { TextStyle } from "../../abstract-document/styles/text-style.js";
4
4
  import { Image } from "../../abstract-document/atoms/image.js";
5
5
  import { Resources } from "../../abstract-document/index.js";
6
- import { fromBase64 } from "../shared/base-64.js";
6
+ import { fromBase64, rawSvgPrefix } from "../shared/base-64.js";
7
+ import * as AD from "../../abstract-document/index.js";
8
+
9
+ const abstractDocPointsToDocxPxRatio = 1; // Set to 1 for now to minimize impact. Can be adjusted to better match PDF image dimensions
10
+
11
+ const emptyPNG =
12
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO6pH7sAAAAASUVORK5CYII=";
13
+
14
+ const fallbackImage = {
15
+ type: "png" as const,
16
+ data: fromBase64(emptyPNG),
17
+ };
18
+
19
+ export function renderImage(
20
+ image: Image,
21
+ textStyle: TextStyle,
22
+ resources: Resources.Resources
23
+ ): ImageRun {
7
24
 
8
- export function renderImage(image: Image, textStyle: TextStyle, resources: Resources.Resources): ImageRun {
9
25
  const aImage = image.imageResource.abstractImage;
10
- const images = aImage.components.map((c: AbstractImage.Component) =>
11
- abstractComponentToDocX(c, image.width, image.height, textStyle, resources, 0)
12
- );
13
- return images[0]!;
26
+
27
+ const firstComp = aImage.components[0];
28
+ const resource =
29
+ !aImage.size.width &&
30
+ !aImage.size.height &&
31
+ firstComp?.type === "binaryimage" &&
32
+ firstComp.data.type === "url"
33
+ ? resources.imageResources?.[firstComp.data.url] ?? image.imageResource
34
+ : image.imageResource;
35
+
36
+ const rect = resourceRect(resource, {
37
+ width: image.width,
38
+ height: image.height,
39
+ });
40
+
41
+ const imgW = resource.abstractImage.size.width || 1;
42
+ const imgH = resource.abstractImage.size.height || 1;
43
+
44
+ const scaleX = rect.width / imgW;
45
+ const scaleY = rect.height / imgH;
46
+ const scale = Math.min(scaleX, scaleY);
47
+
48
+ const drawnW = imgW * scale;
49
+ const drawnH = imgH * scale;
50
+
51
+ const transformation: IMediaTransformation = { width: drawnW * abstractDocPointsToDocxPxRatio, height: drawnH * abstractDocPointsToDocxPxRatio};
52
+
53
+ const directImage = tryCreateDirectImageRun(resource, transformation, resources);
54
+ if (directImage) {
55
+ return directImage;
56
+ }
57
+
58
+ const svg = abstractImageToSvg(resource.abstractImage, resources, textStyle);
59
+
60
+ return new ImageRun({
61
+ type: "svg",
62
+ data: Buffer.from(svg, "utf8"),
63
+ transformation,
64
+ fallback: fallbackImage,
65
+ });
66
+ }
67
+
68
+ function tryCreateDirectImageRun(
69
+ resource: AD.ImageResource.ImageResource,
70
+ transformation: IMediaTransformation,
71
+ resources: Resources.Resources
72
+ ): ImageRun | undefined {
73
+ if (resource.abstractImage.components.length !== 1) {
74
+ return undefined;
75
+ }
76
+
77
+ const component = resource.abstractImage.components[0];
78
+ if (component.type !== "binaryimage") {
79
+ return undefined;
80
+ }
81
+
82
+ return binaryImageToImageRun(component, transformation, resources);
83
+ }
84
+
85
+ function binaryImageToImageRun(
86
+ component: AbstractImage.BinaryImage,
87
+ transformation: IMediaTransformation,
88
+ resources: Resources.Resources
89
+ ): ImageRun | undefined {
90
+ const format = component.format.toLowerCase();
91
+
92
+ if (component.data.type === "bytes") {
93
+ if (format === "png" || format === "jpg" || format === "jpeg") {
94
+ return new ImageRun({
95
+ type: format === "png" ? "png" : "jpg",
96
+ data: Buffer.from(
97
+ component.data.bytes.buffer,
98
+ component.data.bytes.byteOffset,
99
+ component.data.bytes.byteLength
100
+ ),
101
+ transformation,
102
+ } as any);
103
+ }
104
+
105
+ if (format === "svg") {
106
+ return new ImageRun({
107
+ type: "svg",
108
+ data: Buffer.from(component.data.bytes),
109
+ transformation,
110
+ fallback: fallbackImage,
111
+ });
112
+ }
113
+ }
114
+
115
+ if (component.data.type === "url") {
116
+ const nestedResource = resources.imageResources?.[component.data.url];
117
+
118
+ if (nestedResource) {
119
+ const direct = tryCreateDirectImageRun(nestedResource, transformation, resources);
120
+ return direct;
121
+ }
122
+
123
+ if (component.data.url.startsWith(rawSvgPrefix)) {
124
+ const svg = decodeURIComponent(component.data.url.slice(rawSvgPrefix.length));
125
+
126
+ return new ImageRun({
127
+ type: "svg",
128
+ data: Buffer.from(svg, "utf8"),
129
+ transformation,
130
+ fallback: fallbackImage,
131
+ });
132
+ }
133
+
134
+ const match = /^data:(.+?);base64,(.*)$/.exec(component.data.url);
135
+ if (match) {
136
+ const mimeType = match[1].toLowerCase();
137
+ const data = fromBase64(match[2]);
138
+
139
+ if (mimeType.includes("png")) {
140
+ return new ImageRun({ data, transformation, type: "png" } as any);
141
+ }
142
+
143
+ if (mimeType.includes("jpeg") || mimeType.includes("jpg")) {
144
+ return new ImageRun({ data, transformation, type: "jpg" } as any);
145
+ }
146
+
147
+ if (mimeType.includes("svg")) {
148
+ return new ImageRun({ type: "svg", data, transformation, fallback: fallbackImage});
149
+ }
150
+ }
151
+ }
152
+
153
+ return undefined;
154
+ }
155
+
156
+ function abstractImageToSvg(
157
+ image: AbstractImage.AbstractImage,
158
+ resources: Resources.Resources,
159
+ textStyle: TextStyle
160
+ ): string {
161
+ const width = image.size.width || 1;
162
+ const height = image.size.height || 1;
163
+
164
+ const children = image.components
165
+ .map((component) => componentToSvg(component, resources, textStyle, 0))
166
+ .filter(Boolean)
167
+ .join("");
168
+
169
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${children}</svg>`;
14
170
  }
15
171
 
16
- function abstractComponentToDocX(
172
+ function componentToSvg(
17
173
  component: AbstractImage.Component,
18
- rectWidth: number,
19
- rectHeight: number,
20
- _textStyle: TextStyle,
21
174
  resources: Resources.Resources,
175
+ textStyle: TextStyle,
22
176
  circuitBreaker: number
23
- ): ImageRun | undefined {
177
+ ): string {
24
178
  if (++circuitBreaker > 20) {
25
- return undefined;
179
+ return "";
26
180
  }
181
+
27
182
  switch (component.type) {
28
- // case "group":
29
- // component.children.forEach((c) => abstractComponentToPdf(c, textStyle));
30
- // break;
183
+ case "group":
184
+ return component.children
185
+ .map((child) => componentToSvg(child, resources, textStyle, circuitBreaker))
186
+ .join("");
187
+
188
+ case "subimage": {
189
+ const scaleX = component.size.width / (component.image.size.width || 1);
190
+ const scaleY = component.size.height / (component.image.size.height || 1);
191
+ const scale = Math.min(scaleX, scaleY);
192
+
193
+ const children = component.image.components
194
+ .map((child) => componentToSvg(child, resources, textStyle, circuitBreaker))
195
+ .join("");
196
+
197
+ return `<g transform="translate(${component.topLeft.x} ${component.topLeft.y}) scale(${scale})">${children}</g>`;
198
+ }
199
+
31
200
  case "binaryimage":
32
- const format = component.format.toLowerCase();
33
- // const w = component.bottomRight.x - component.topLeft.x;
34
- // const h = component.bottomRight.y - component.topLeft.y;
35
- // const scale = Math.min(rectWidth / (w || 1), rectHeight / (h || 1));
36
- // const transformation: IMediaTransformation = { width: w * scale, height: h * scale };
37
- const transformation: IMediaTransformation = { width: rectWidth, height: rectHeight };
38
- if (component.data.type === "bytes" && (format === "png" || format === "jpg")) {
39
- return new ImageRun({
40
- data: Buffer.from(
41
- component.data.bytes.buffer,
42
- component.data.bytes.byteOffset,
43
- component.data.bytes.byteLength
44
- ),
45
- transformation,
46
- });
47
- }
48
- if (component.data.type === "url") {
49
- const imageResource = resources.imageResources?.[component.data.url];
50
- if (imageResource) {
51
- return abstractComponentToDocX(
52
- imageResource.abstractImage.components[0],
53
- rectWidth,
54
- rectHeight,
55
- _textStyle,
56
- resources,
57
- circuitBreaker
58
- );
59
- }
60
- const match = /^data:.+?;base64,(.*)$/.exec(component.data.url);
61
- if (match) {
62
- return new ImageRun({ data: fromBase64(match[1]), transformation });
63
- }
64
- }
65
- break;
66
- //else if (format === "svg") {
67
- // const svg = new TextDecoder().decode(component.data);
68
- // const imageWidth = component.bottomRight.x - component.topLeft.x;
69
- // const imageHeight = component.bottomRight.y - component.topLeft.y;
70
- // svgToPdfKit(pdf, svg, component.topLeft.x, component.topLeft.y, {
71
- // width: imageWidth,
72
- // height: imageHeight,
73
- // preserveAspectRatio: "xMinYMin",
74
- // //fontCallback: component.overrideSvgFont
75
- // // ? (_family: string, _bold: boolean, _italic: boolean) => {
76
- // // return getFontNameStyle(textStyle);
77
- // // }
78
- // // : undefined,
79
- // // fontCallback: (_family: string, _bold: boolean, _italic: boolean) => {
80
- // // return getFontNameStyle(textStyle);
81
- // // }
82
- // });
83
- // }
84
- //break;
85
- // case "subimage":
86
- // break;
87
- // case "line":
88
- // pdf
89
- // .lineWidth(component.strokeThickness)
90
- // .moveTo(component.start.x, component.start.y)
91
- // .lineTo(component.end.x, component.end.y)
92
- // .strokeOpacity(colorToOpacity(component.strokeColor))
93
- // .stroke(colorToRgb(component.strokeColor));
94
- // break;
95
- // case "polyline":
96
- // for (let i = 0; i < component.points.length; ++i) {
97
- // const p = component.points[i];
98
- // if (i === 0) {
99
- // pdf.moveTo(p.x, p.y);
100
- // } else {
101
- // pdf.lineTo(p.x, p.y);
102
- // }
103
- // }
104
- // pdf
105
- // .lineWidth(component.strokeThickness)
106
- // .strokeOpacity(colorToOpacity(component.strokeColor))
107
- // .stroke(colorToRgb(component.strokeColor));
108
- // break;
109
- // case "text":
110
- // if (component.clockwiseRotationDegrees !== 0) {
111
- // pdf.save();
112
- // pdf.rotate(component.clockwiseRotationDegrees, {
113
- // origin: [component.position.x, component.position.y],
114
- // });
115
- // }
116
- // pdf.font(component.fontFamily).fontSize(component.fontSize);
117
- // const stringWidth = pdf.widthOfString(component.text);
118
- // const stringHeight = pdf.currentLineHeight();
119
- // const dx =
120
- // component.horizontalGrowthDirection === "left"
121
- // ? -stringWidth
122
- // : component.horizontalGrowthDirection === "uniform"
123
- // ? -stringWidth * 0.5
124
- // : 0;
125
- // const dy =
126
- // component.verticalGrowthDirection === "up"
127
- // ? -stringHeight
128
- // : component.verticalGrowthDirection === "uniform"
129
- // ? -stringHeight * 0.5
130
- // : 0;
131
- // pdf
132
- // .font(component.fontFamily)
133
- // .fontSize(component.fontSize)
134
- // .fillColor(colorToRgb(component.textColor))
135
- // .text(component.text, component.position.x + dx, component.position.y + dy, { lineBreak: false });
136
- // if (component.clockwiseRotationDegrees !== 0) {
137
- // pdf.restore();
138
- // }
139
- // break;
140
- // case "ellipse":
141
- // const width = component.bottomRight.x - component.topLeft.x;
142
- // const height = component.bottomRight.y - component.topLeft.y;
143
- // const centerX = component.topLeft.x + width * 0.5;
144
- // const centerY = component.topLeft.y + height * 0.5;
145
- // pdf
146
- // .lineWidth(component.strokeThickness)
147
- // .ellipse(centerX, centerY, width * 0.5, height * 0.5)
148
- // .strokeOpacity(colorToOpacity(component.strokeColor))
149
- // .fillOpacity(colorToOpacity(component.fillColor))
150
- // .fillAndStroke(colorToRgb(component.fillColor), colorToRgb(component.strokeColor));
151
- // break;
152
- // case "polygon":
153
- // pdf
154
- // .lineWidth(component.strokeThickness)
155
- // .strokeOpacity(colorToOpacity(component.strokeColor))
156
- // .fillOpacity(colorToOpacity(component.fillColor))
157
- // .polygon(...component.points.map((p) => [p.x, p.y]))
158
- // .fillAndStroke(colorToRgb(component.fillColor), colorToRgb(component.strokeColor));
159
- // break;
160
- // case "rectangle":
161
- // const rWidth = component.bottomRight.x - component.topLeft.x;
162
- // const rHeight = component.bottomRight.y - component.topLeft.y;
163
- // pdf
164
- // .lineWidth(component.strokeThickness)
165
- // .strokeOpacity(colorToOpacity(component.strokeColor))
166
- // .fillOpacity(colorToOpacity(component.fillColor))
167
- // .rect(component.topLeft.x, component.topLeft.y, rWidth, rHeight)
168
- // .fillAndStroke(colorToRgb(component.fillColor), colorToRgb(component.strokeColor));
169
- // break;
201
+ return binaryImageToSvg(component, resources, textStyle, circuitBreaker);
202
+
203
+ case "line":
204
+ return `<line x1="${component.start.x}" y1="${component.start.y}" x2="${component.end.x}" y2="${component.end.y}" ${strokeAttrs(component.strokeColor, component.strokeThickness, component.strokeDashStyle)} />`;
205
+
206
+ case "polyline":
207
+ return `<polyline points="${component.points.map((p) => `${p.x},${p.y}`).join(" ")}" fill="none" ${strokeAttrs(component.strokeColor, component.strokeThickness, component.strokeDashStyle)} />`;
208
+
209
+ case "polygon":
210
+ return `<polygon points="${component.points.map((p) => `${p.x},${p.y}`).join(" ")}" ${fillAttrs(component.fillColor)} ${strokeAttrs(component.strokeColor, component.strokeThickness, component.strokeDashStyle)} />`;
211
+
212
+ case "rectangle": {
213
+ const width = component.bottomRight.x - component.topLeft.x;
214
+ const height = component.bottomRight.y - component.topLeft.y;
215
+
216
+ return `<rect x="${component.topLeft.x}" y="${component.topLeft.y}" width="${width}" height="${height}" ${fillAttrs(component.fillColor)} ${strokeAttrs(component.strokeColor, component.strokeThickness, component.strokeDashStyle)} />`;
217
+ }
218
+
219
+ case "ellipse": {
220
+ const width = component.bottomRight.x - component.topLeft.x;
221
+ const height = component.bottomRight.y - component.topLeft.y;
222
+ const cx = component.topLeft.x + width * 0.5;
223
+ const cy = component.topLeft.y + height * 0.5;
224
+
225
+ return `<ellipse cx="${cx}" cy="${cy}" rx="${width * 0.5}" ry="${height * 0.5}" ${fillAttrs(component.fillColor)} ${strokeAttrs(component.strokeColor, component.strokeThickness, component.strokeDashStyle)} />`;
226
+ }
227
+
228
+ case "text":
229
+ return textComponentToSvg(component);
230
+
170
231
  default:
171
- return undefined;
232
+ return "";
172
233
  }
173
- return undefined;
174
234
  }
175
235
 
176
- // function colorToOpacity(color: AbstractImage.Color): number {
177
- // return color.a / 255;
178
- // }
236
+ function binaryImageToSvg(
237
+ component: AbstractImage.BinaryImage,
238
+ resources: Resources.Resources,
239
+ textStyle: TextStyle,
240
+ circuitBreaker: number
241
+ ): string {
242
+ const width = component.bottomRight.x - component.topLeft.x;
243
+ const height = component.bottomRight.y - component.topLeft.y;
244
+ const format = component.format.toLowerCase();
245
+
246
+ if (component.data.type === "url") {
247
+ const imageResource = resources.imageResources?.[component.data.url];
248
+
249
+ if (imageResource) {
250
+ const scaleX = width / (imageResource.abstractImage.size.width || 1);
251
+ const scaleY = height / (imageResource.abstractImage.size.height || 1);
252
+ const scale = Math.min(scaleX, scaleY);
253
+
254
+ const children = imageResource.abstractImage.components
255
+ .map((child) => componentToSvg(child, resources, textStyle, circuitBreaker))
256
+ .join("");
257
+
258
+ return `<g transform="translate(${component.topLeft.x} ${component.topLeft.y}) scale(${scale})">${children}</g>`;
259
+ }
260
+
261
+ if (component.data.url.startsWith(rawSvgPrefix)) {
262
+ const svg = decodeURIComponent(component.data.url.slice(rawSvgPrefix.length));
263
+ return `<g transform="translate(${component.topLeft.x} ${component.topLeft.y})">${svg}</g>`;
264
+ }
265
+
266
+ return `<image x="${component.topLeft.x}" y="${component.topLeft.y}" width="${width}" height="${height}" href="${escapeXml(component.data.url)}" />`;
267
+ }
268
+
269
+ if (component.data.type === "bytes") {
270
+ if (format === "png" || format === "jpg" || format === "jpeg") {
271
+ const mime = format === "png" ? "image/png" : "image/jpeg";
272
+ const base64 = Buffer.from(
273
+ component.data.bytes.buffer,
274
+ component.data.bytes.byteOffset,
275
+ component.data.bytes.byteLength
276
+ ).toString("base64");
277
+
278
+ return `<image x="${component.topLeft.x}" y="${component.topLeft.y}" width="${width}" height="${height}" href="data:${mime};base64,${base64}" />`;
279
+ }
280
+
281
+ if (format === "svg") {
282
+ const svg = new TextDecoder().decode(component.data.bytes);
283
+ return `<g transform="translate(${component.topLeft.x} ${component.topLeft.y})">${svg}</g>`;
284
+ }
285
+ }
286
+
287
+ return "";
288
+ }
289
+
290
+ function textComponentToSvg(component: AbstractImage.Text): string {
291
+ const rotation =
292
+ component.clockwiseRotationDegrees !== 0
293
+ ? ` transform="rotate(${component.clockwiseRotationDegrees} ${component.position.x} ${component.position.y})"`
294
+ : "";
295
+
296
+ const anchor =
297
+ component.horizontalGrowthDirection === "left"
298
+ ? "end"
299
+ : component.horizontalGrowthDirection === "uniform"
300
+ ? "middle"
301
+ : "start";
302
+
303
+ const dominantBaseline =
304
+ component.verticalGrowthDirection === "up"
305
+ ? "text-after-edge"
306
+ : component.verticalGrowthDirection === "uniform"
307
+ ? "middle"
308
+ : "text-before-edge";
179
309
 
180
- // function colorToRgb(color: AbstractImage.Color): Array<number> {
181
- // return [color.r, color.g, color.b];
182
- // }
310
+ return `<text x="${component.position.x}" y="${component.position.y}" font-family="${escapeXml(component.fontFamily)}" font-size="${component.fontSize}" font-weight="${component.fontWeight}" font-style="${component.italic ? "italic" : "normal"}" fill="${colorToCss(component.textColor)}" text-anchor="${anchor}" dominant-baseline="${dominantBaseline}"${rotation}>${escapeXml(component.text)}</text>`;
311
+ }
312
+
313
+ function fillAttrs(color: AbstractImage.Color): string {
314
+ return `fill="${colorToCss(color)}" fill-opacity="${colorToOpacity(color)}"`;
315
+ }
316
+
317
+ function strokeAttrs(
318
+ color: AbstractImage.Color,
319
+ thickness: number,
320
+ dashStyle: AbstractImage.DashStyle
321
+ ): string {
322
+ const dashArray =
323
+ dashStyle.dashes.length > 0
324
+ ? ` stroke-dasharray="${dashStyle.dashes.filter((dash) => dash !== 0).join(" ")}" stroke-dashoffset="${dashStyle.offset}"`
325
+ : "";
326
+
327
+ return `stroke="${colorToCss(color)}" stroke-opacity="${colorToOpacity(color)}" stroke-width="${thickness}"${dashArray}`;
328
+ }
329
+
330
+ function colorToOpacity(color: AbstractImage.Color): number {
331
+ return color.a / 255;
332
+ }
333
+
334
+ function colorToCss(color: AbstractImage.Color): string {
335
+ return `rgb(${color.r},${color.g},${color.b})`;
336
+ }
337
+
338
+ function escapeXml(value: string): string {
339
+ return value
340
+ .replace(/&/g, "&amp;")
341
+ .replace(/"/g, "&quot;")
342
+ .replace(/</g, "&lt;")
343
+ .replace(/>/g, "&gt;");
344
+ }
345
+
346
+ function resourceRect(resource: AD.ImageResource.ImageResource, rect: AbstractImage.Size): AbstractImage.Size {
347
+ const ai = resource.abstractImage;
348
+ const rectWidth = rect.width || ai.size.width * (rect.height / (ai.size.height || 1));
349
+ const rectHeight = rect.height || ai.size.height * (rect.width / (ai.size.width || 1));
350
+
351
+ return { width: rectWidth, height: rectHeight };
352
+ }