reamkit 1.23.0 → 1.24.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 (57) hide show
  1. package/dist/esm/core/bmp.d.ts +33 -0
  2. package/dist/esm/core/bmp.js +276 -0
  3. package/dist/esm/core/document-model/types.d.ts +37 -0
  4. package/dist/esm/core/drawingml/chart-geometry.js +3 -2
  5. package/dist/esm/core/drawingml/diagram/colors.d.ts +52 -0
  6. package/dist/esm/core/drawingml/diagram/colors.js +125 -0
  7. package/dist/esm/core/drawingml/diagram/data-model.d.ts +47 -0
  8. package/dist/esm/core/drawingml/diagram/data-model.js +119 -0
  9. package/dist/esm/core/drawingml/diagram/layout-engine.d.ts +46 -0
  10. package/dist/esm/core/drawingml/diagram/layout-engine.js +948 -0
  11. package/dist/esm/core/drawingml/diagram/run.d.ts +37 -0
  12. package/dist/esm/core/drawingml/diagram/run.js +50 -0
  13. package/dist/esm/core/drawingml/diagram/to-drawing.d.ts +16 -0
  14. package/dist/esm/core/drawingml/diagram/to-drawing.js +86 -0
  15. package/dist/esm/core/drawingml/preset-geometry.js +116 -6
  16. package/dist/esm/core/drawingml/text-warp.d.ts +58 -0
  17. package/dist/esm/core/drawingml/text-warp.js +355 -0
  18. package/dist/esm/core/drawingml/theme-parser.js +9 -3
  19. package/dist/esm/core/font/measure.d.ts +12 -0
  20. package/dist/esm/core/font/measure.js +36 -0
  21. package/dist/esm/core/images.d.ts +30 -2
  22. package/dist/esm/core/images.js +121 -5
  23. package/dist/esm/core/metafile/emf.js +144 -16
  24. package/dist/esm/core/metafile/picture.d.ts +49 -0
  25. package/dist/esm/core/metafile/picture.js +70 -1
  26. package/dist/esm/core/metafile/wmf.js +53 -4
  27. package/dist/esm/core/ole/escher-blip.js +11 -1
  28. package/dist/esm/excel/print-model.js +2 -2
  29. package/dist/esm/excel/sheet-drawing.js +1 -1
  30. package/dist/esm/excel/sheet-to-flow.js +16 -2
  31. package/dist/esm/html/html-writer.js +2 -1
  32. package/dist/esm/layout/page-doc.d.ts +35 -0
  33. package/dist/esm/layout/page-doc.js +14 -1
  34. package/dist/esm/layout/styled-layout.js +176 -15
  35. package/dist/esm/pdf/shading.js +8 -6
  36. package/dist/esm/pdf/styled-page-emitter.js +42 -3
  37. package/dist/esm/pdf/vector-graphics.js +1 -1
  38. package/dist/esm/pdf-reader/image-decode.js +1 -1
  39. package/dist/esm/pptx/ppt/ppt-reader.js +95 -27
  40. package/dist/esm/pptx/ppt/ppt-text.d.ts +41 -0
  41. package/dist/esm/pptx/ppt/ppt-text.js +327 -37
  42. package/dist/esm/pptx/pptx-reader.js +90 -10
  43. package/dist/esm/pptx/slide-parser.d.ts +4 -1
  44. package/dist/esm/pptx/slide-parser.js +99 -13
  45. package/dist/esm/pptx/sp-helpers.d.ts +7 -3
  46. package/dist/esm/pptx/sp-helpers.js +45 -7
  47. package/dist/esm/pptx/table-style.d.ts +7 -0
  48. package/dist/esm/pptx/table-style.js +59 -7
  49. package/dist/esm/svg/svg-writer.js +1 -0
  50. package/dist/esm/word/document-parser.d.ts +4 -1
  51. package/dist/esm/word/document-parser.js +4 -1
  52. package/dist/esm/word/docx-reader.js +26 -13
  53. package/dist/esm/word/docx-writer.js +4 -0
  54. package/dist/esm/word/drawing-parser.d.ts +20 -0
  55. package/dist/esm/word/drawing-parser.js +37 -4
  56. package/dist/esm/word/table-parser.js +3 -0
  57. package/package.json +1 -1
@@ -0,0 +1,355 @@
1
+ //#region src/core/drawingml/text-warp.ts
2
+ /** Distance from the box's centre line, `0` at the middle and `1` at either edge. */
3
+ var fromCentre = (u) => Math.abs(2 * u - 1);
4
+ /** A band of constant height `1 - drop` whose top edge is `top`. */
5
+ var band = (top, drop) => ({
6
+ top,
7
+ bottom: top + (1 - drop)
8
+ });
9
+ /**
10
+ * §20.1.10.76 ST_TextShapeType — the warp each preset applies, keyed by its
11
+ * name. `adj` arrives as the `a:avLst` adjustment in hundred-thousandths, or
12
+ * the preset's own default when the file states none.
13
+ */
14
+ var ENVELOPES = new Map([
15
+ ["textPlain", {
16
+ defaultAdj: 0,
17
+ at: () => ({
18
+ top: 0,
19
+ bottom: 1
20
+ })
21
+ }],
22
+ ["textStop", {
23
+ defaultAdj: 12500,
24
+ at: (u, adj) => {
25
+ const t = adj * Math.max(0, (fromCentre(u) - .375) / .625);
26
+ return {
27
+ top: t,
28
+ bottom: 1 - t
29
+ };
30
+ }
31
+ }],
32
+ ["textTriangle", {
33
+ defaultAdj: 5e4,
34
+ at: (u, adj) => ({
35
+ top: adj * fromCentre(u),
36
+ bottom: 1
37
+ })
38
+ }],
39
+ ["textTriangleInverted", {
40
+ defaultAdj: 5e4,
41
+ at: (u, adj) => ({
42
+ top: 0,
43
+ bottom: 1 - adj * fromCentre(u)
44
+ })
45
+ }],
46
+ ["textChevron", {
47
+ defaultAdj: 25e3,
48
+ at: (u, adj) => band(adj * fromCentre(u), adj)
49
+ }],
50
+ ["textChevronInverted", {
51
+ defaultAdj: 25e3,
52
+ at: (u, adj) => band(adj * (1 - fromCentre(u)), adj)
53
+ }],
54
+ ["textCanUp", {
55
+ defaultAdj: 14e3,
56
+ at: (u, adj) => band(adj * fromCentre(u) ** 4, adj)
57
+ }],
58
+ ["textCanDown", {
59
+ defaultAdj: 14e3,
60
+ at: (u, adj) => band(adj * (1 - fromCentre(u) ** 4), adj)
61
+ }],
62
+ ["textWave1", {
63
+ defaultAdj: 6250,
64
+ at: (u, adj) => band(adj * (1 - Math.sin(2 * Math.PI * u)), 2 * adj)
65
+ }],
66
+ ["textWave2", {
67
+ defaultAdj: 6250,
68
+ at: (u, adj) => band(adj * (1 + Math.sin(2 * Math.PI * u)), 2 * adj)
69
+ }],
70
+ ["textDoubleWave1", {
71
+ defaultAdj: 6250,
72
+ at: (u, adj) => band(adj * (1 - Math.sin(4 * Math.PI * u)), 2 * adj)
73
+ }],
74
+ ["textWave4", {
75
+ defaultAdj: 6250,
76
+ at: (u, adj) => band(adj * (1 + Math.sin(4 * Math.PI * u)), 2 * adj)
77
+ }],
78
+ ["textInflate", {
79
+ defaultAdj: 13600,
80
+ at: (u, adj) => {
81
+ const t = adj * fromCentre(u) ** 2;
82
+ return {
83
+ top: t,
84
+ bottom: 1 - t
85
+ };
86
+ }
87
+ }],
88
+ ["textDeflate", {
89
+ defaultAdj: 37500,
90
+ at: (u, adj) => {
91
+ const t = adj * (1 - fromCentre(u) ** 2.23);
92
+ return {
93
+ top: t,
94
+ bottom: 1 - t
95
+ };
96
+ }
97
+ }],
98
+ ["textInflateTop", {
99
+ defaultAdj: 31800,
100
+ at: (u, adj) => ({
101
+ top: adj * fromCentre(u) ** 2,
102
+ bottom: 1
103
+ })
104
+ }],
105
+ ["textDeflateTop", {
106
+ defaultAdj: 47e3,
107
+ at: (u, adj) => ({
108
+ top: adj * (1 - fromCentre(u) ** 2),
109
+ bottom: 1
110
+ })
111
+ }],
112
+ ["textInflateBottom", {
113
+ defaultAdj: 31800,
114
+ at: (u, adj) => ({
115
+ top: 0,
116
+ bottom: 1 - adj * fromCentre(u) ** 2
117
+ })
118
+ }],
119
+ ["textDeflateBottom", {
120
+ defaultAdj: 47e3,
121
+ at: (u, adj) => ({
122
+ top: 0,
123
+ bottom: 1 - adj * (1 - fromCentre(u) ** 2)
124
+ })
125
+ }],
126
+ ["textDeflateInflate", {
127
+ defaultAdj: 19800,
128
+ at: (u, adj) => ({
129
+ top: 0,
130
+ bottom: .268 + adj * fromCentre(u) ** 2
131
+ })
132
+ }],
133
+ ["textDeflateInflateDeflate", {
134
+ defaultAdj: 4600,
135
+ at: (u, adj) => ({
136
+ top: 0,
137
+ bottom: .258 + adj * fromCentre(u) ** 2
138
+ })
139
+ }],
140
+ ["textFadeRight", {
141
+ defaultAdj: 33333,
142
+ at: (u, adj) => ({
143
+ top: adj * u,
144
+ bottom: 1 - adj * u
145
+ })
146
+ }],
147
+ ["textFadeLeft", {
148
+ defaultAdj: 33333,
149
+ at: (u, adj) => ({
150
+ top: adj * (1 - u),
151
+ bottom: 1 - adj * (1 - u)
152
+ })
153
+ }],
154
+ ["textFadeUp", {
155
+ defaultAdj: 1e5,
156
+ at: (u, adj) => ({
157
+ top: adj * fromCentre(u) ** 2,
158
+ bottom: 1
159
+ })
160
+ }],
161
+ ["textFadeDown", {
162
+ defaultAdj: 1e5,
163
+ at: (u, adj) => ({
164
+ top: 0,
165
+ bottom: 1 - adj * fromCentre(u) ** 2
166
+ })
167
+ }],
168
+ ["textSlantUp", {
169
+ defaultAdj: 55500,
170
+ at: (u, adj) => band(adj * (1 - u), adj)
171
+ }],
172
+ ["textSlantDown", {
173
+ defaultAdj: 44400,
174
+ at: (u, adj) => band(adj * u, adj)
175
+ }],
176
+ ["textCascadeUp", {
177
+ defaultAdj: 55500,
178
+ at: (u, adj) => ({
179
+ top: .138 * (1 - u),
180
+ bottom: 1 - adj + adj * (1 - u)
181
+ })
182
+ }],
183
+ ["textCascadeDown", {
184
+ defaultAdj: 55500,
185
+ at: (u, adj) => ({
186
+ top: .138 * u,
187
+ bottom: 1 - adj + adj * u
188
+ })
189
+ }],
190
+ ["textCurveUp", {
191
+ defaultAdj: 45600,
192
+ at: (u, adj) => curve(1 - u, adj)
193
+ }],
194
+ ["textCurveDown", {
195
+ defaultAdj: 45600,
196
+ at: (u, adj) => curve(u, adj)
197
+ }]
198
+ ]);
199
+ /**
200
+ * The ring presets, which do not hang text between two edges at all: they wind
201
+ * it once round an ellipse inscribed in the box. Both start at the box's left
202
+ * edge; `Inside` climbs over the top with each letter's head pointing out of
203
+ * the ring, `Outside` dips under the bottom with each letter's head pointing
204
+ * into it — which is why half of either one reads upside down.
205
+ *
206
+ * The band the letters occupy is a fixed share of the ring whatever the text:
207
+ * eight letters and thirty-two fill exactly the same annulus, so it is the
208
+ * preset and not the string that fixes the inner radius.
209
+ */
210
+ var RING_INNER = .34;
211
+ var RINGS = new Map([["textRingInside", {
212
+ clockwise: true,
213
+ headOut: true
214
+ }], ["textRingOutside", {
215
+ clockwise: false,
216
+ headOut: false
217
+ }]]);
218
+ /**
219
+ * The `textCurve*` pair: the baseline is an arc over the box and the band
220
+ * narrows towards the end the text runs to.
221
+ *
222
+ * @param u Position across the box, already turned the way the preset runs.
223
+ * @param adj How much of the band's height the taper takes by the far end.
224
+ * @returns The two edges at `u`.
225
+ */
226
+ function curve(u, adj) {
227
+ const bottom = 1 - .24 * (2 * u - 1) ** 2;
228
+ return {
229
+ top: bottom - (.758 - adj * u ** 1.7),
230
+ bottom
231
+ };
232
+ }
233
+ /**
234
+ * Whether a preset bends text at all. `textNoShape` is the "no warp" member of
235
+ * the enumeration and by far its most common value — two thirds of the decks
236
+ * that mention a warp state only this one — and a body carrying it is an
237
+ * ordinary text box, NOT WordArt stretched to its shape.
238
+ *
239
+ * @param preset The `a:prstTxWarp @prst` value.
240
+ * @returns Whether {@link textWarpEdges} can bend text through this preset.
241
+ */
242
+ function isTextWarp(preset) {
243
+ return ENVELOPES.has(preset) || RINGS.has(preset);
244
+ }
245
+ /**
246
+ * The matrix that places one glyph of a warped body.
247
+ *
248
+ * Maps the glyph's own text space — x along its advance, y UP from its
249
+ * baseline — into the page's top-left frame, as `[a, b, c, d, e, f]` with
250
+ * `px = a·x + c·y + e` and `py = b·x + d·y + f`. Upright text therefore has a
251
+ * negative `d`: the glyph's y runs against the page's.
252
+ *
253
+ * @param frame The two frames, from the laid-out line.
254
+ * @param glyphX The glyph's left edge in the un-warped block.
255
+ * @param advance How wide the glyph is there.
256
+ * @param baselineY The line's baseline in the un-warped block.
257
+ * @returns The six numbers, or `undefined` where the warp leaves no room to
258
+ * draw — the pinched end of a fade, or a preset this file cannot bend.
259
+ */
260
+ function warpGlyphMatrix(frame, glyphX, advance, baselineY) {
261
+ const u = (glyphX + advance / 2 - frame.srcX) / frame.srcWidth;
262
+ const v = (baselineY - frame.srcTop) / frame.srcHeight;
263
+ const ring = RINGS.get(frame.preset);
264
+ if (ring) return ringMatrix(frame, ring, u, v, glyphX);
265
+ return envelopeMatrix(frame, u, v, glyphX, advance);
266
+ }
267
+ /** The glyph matrix for a warp that hangs text between a top and a bottom edge. */
268
+ function envelopeMatrix(frame, u, v, glyphX, advance) {
269
+ const edges = textWarpEdges(frame.preset, u, frame.adjust);
270
+ if (!edges) return void 0;
271
+ const height = edges.bottom - edges.top;
272
+ if (!(height > 0)) return void 0;
273
+ const sx = frame.boxWidth / frame.srcWidth;
274
+ const sy = height * frame.boxHeight / frame.srcHeight;
275
+ const step = .004;
276
+ const lo = Math.max(0, u - step);
277
+ const hi = Math.min(1, u + step);
278
+ const at = (q) => {
279
+ const e = textWarpEdges(frame.preset, q, frame.adjust);
280
+ return e ? e.top + v * (e.bottom - e.top) : void 0;
281
+ };
282
+ const before = at(lo);
283
+ const after = at(hi);
284
+ const slope = before !== void 0 && after !== void 0 && hi > lo ? (after - before) * frame.boxHeight / ((hi - lo) * frame.boxWidth) : 0;
285
+ const x0 = frame.boxX + (glyphX - frame.srcX) / frame.srcWidth * frame.boxWidth;
286
+ const mid = edges.top + v * height;
287
+ const y0 = frame.boxY + mid * frame.boxHeight - slope * advance * sx / 2;
288
+ return [
289
+ sx,
290
+ slope * sx,
291
+ 0,
292
+ -sy,
293
+ x0,
294
+ y0
295
+ ];
296
+ }
297
+ /**
298
+ * The glyph matrix for a ring: one turn round the ellipse inscribed in the box.
299
+ *
300
+ * The band is a CONSTANT inset from that ellipse, not a scaled copy of it. A
301
+ * scaled copy would be the natural reading — a circular annulus squashed to the
302
+ * box's proportions — but it makes the band four times deeper at the ring's
303
+ * sides than at its top on a box four times wider than tall, and the letters
304
+ * there come out as spikes radiating from the centre. Held at a constant depth
305
+ * the ring's outer edge still touches the box all the way round, which is what
306
+ * both references draw and what a box of capitals measures.
307
+ */
308
+ function ringMatrix(frame, ring, _u, v, glyphX) {
309
+ const a = frame.boxWidth / 2;
310
+ const b = frame.boxHeight / 2;
311
+ if (!(a > 0 && b > 0 && frame.srcWidth > 0 && frame.srcHeight > 0)) return void 0;
312
+ const uLeft = (glyphX - frame.srcX) / frame.srcWidth;
313
+ const turn = ring.clockwise ? -1 : 1;
314
+ const psi = Math.PI + turn * 2 * Math.PI * uLeft;
315
+ const cos = Math.cos(psi);
316
+ const sin = Math.sin(psi);
317
+ const rawX = -turn * a * sin;
318
+ const rawY = -turn * b * cos;
319
+ const speed = Math.hypot(rawX, rawY);
320
+ if (!(speed > 0)) return void 0;
321
+ const tx = rawX / speed;
322
+ const ty = rawY / speed;
323
+ const inX = -turn * -ty;
324
+ const inY = -turn * tx;
325
+ const head = ring.headOut ? -1 : 1;
326
+ const depth = (1 - RING_INNER) * b;
327
+ const sT = 2 * Math.PI * speed / frame.srcWidth;
328
+ const sN = depth / frame.srcHeight;
329
+ const inset = (ring.headOut ? v : 1 - v) * depth;
330
+ return [
331
+ sT * tx,
332
+ sT * ty,
333
+ sN * head * inX,
334
+ sN * head * inY,
335
+ frame.boxX + a + a * cos + inX * inset,
336
+ frame.boxY + b - b * sin + inY * inset
337
+ ];
338
+ }
339
+ /**
340
+ * The two edges of a warp at one position across its box.
341
+ *
342
+ * @param preset The `a:prstTxWarp @prst` value.
343
+ * @param u Position across the box, `0` at its left edge and `1` at its right.
344
+ * @param adj The `a:avLst` `adj` guide in hundred-thousandths, when the file states one.
345
+ * @returns The top and bottom edges, `0`…`1` down the box, or `undefined` for a
346
+ * preset this file does not bend (an unknown name, or `textNoShape`).
347
+ */
348
+ function textWarpEdges(preset, u, adj) {
349
+ const env = ENVELOPES.get(preset);
350
+ if (!env) return void 0;
351
+ const a = (adj ?? env.defaultAdj) / 1e5;
352
+ return env.at(Math.min(1, Math.max(0, u)), a);
353
+ }
354
+ //#endregion
355
+ export { isTextWarp, warpGlyphMatrix };
@@ -168,7 +168,13 @@ function parseThemeFillStyles(themeXml) {
168
168
  "a:fmtScheme",
169
169
  "a:fillStyleLst"
170
170
  ]);
171
- return list ? [...poChildren(list)] : [];
171
+ return list ? fillsOf(list) : [];
172
+ }
173
+ function fillsOf(list) {
174
+ return poChildren(list).filter((c) => {
175
+ const tag = poTag(c);
176
+ return tag !== void 0 && tag !== "#text";
177
+ });
172
178
  }
173
179
  /**
174
180
  * Parse a theme's BACKGROUND fill styles (§20.1.4.1.7 `a:bgFillStyleLst`), as
@@ -186,7 +192,7 @@ function parseThemeBgFillStyles(themeXml) {
186
192
  "a:fmtScheme",
187
193
  "a:bgFillStyleLst"
188
194
  ]);
189
- return list ? [...poChildren(list)] : [];
195
+ return list ? fillsOf(list) : [];
190
196
  }
191
197
  /**
192
198
  * Parse a theme's effect styles (§20.1.4.1.15 `a:effectStyleLst`), as the raw
@@ -204,7 +210,7 @@ function parseThemeEffectStyles(themeXml) {
204
210
  "a:fmtScheme",
205
211
  "a:effectStyleLst"
206
212
  ]);
207
- return list ? [...poChildren(list)] : [];
213
+ return list ? fillsOf(list) : [];
208
214
  }
209
215
  var EMU_PER_POINT = 12700;
210
216
  function colorOf(slot) {
@@ -10,6 +10,18 @@ export interface FontMeasure {
10
10
  readonly pdfWidthForGid: (gid: number) => number;
11
11
  /** Shaped width of `text` at `fontSize`, in points. */
12
12
  readonly textWidthPt: (text: string, fontSize: number) => number;
13
+ /**
14
+ * How far this text's INK actually reaches from its baseline, in points:
15
+ * `above` up to its highest glyph edge, `below` down to its lowest. Not the
16
+ * font's line box — "HHH" reaches a cap height and no further, and only a
17
+ * string carrying a descender reaches under the baseline at all. WordArt
18
+ * stretches the ink it draws to fill its shape (§20.1.9.10), so it is the
19
+ * ink and not the line box that has to be measured.
20
+ */
21
+ readonly textInkPt: (text: string, fontSize: number) => {
22
+ above: number;
23
+ below: number;
24
+ };
13
25
  /** Encode `text` as a hex string of 4-digit glyph ids (Identity-H addressing). */
14
26
  readonly encodeTextAsCidHex: (text: string) => string;
15
27
  /**
@@ -52,9 +52,45 @@ function createFontMeasure(parsed, kern = true) {
52
52
  if (hex !== "") parts.push(`<${hex}>`);
53
53
  return `[${parts.join(" ")}] TJ`;
54
54
  };
55
+ const glyf = parsed.tables.get("glyf");
56
+ const glyphInk = (gid) => {
57
+ if (!glyf || gid < 0 || gid >= parsed.numGlyphs) return void 0;
58
+ const start = parsed.glyphOffsets[gid] ?? 0;
59
+ if ((parsed.glyphOffsets[gid + 1] ?? 0) - start < 10) return void 0;
60
+ const at = glyf.offset + start;
61
+ const i16 = (o) => {
62
+ const v = (parsed.raw[at + o] ?? 0) << 8 | (parsed.raw[at + o + 1] ?? 0);
63
+ return v >= 32768 ? v - 65536 : v;
64
+ };
65
+ return {
66
+ yMin: i16(4),
67
+ yMax: i16(8)
68
+ };
69
+ };
70
+ const textInkPt = (text, fontSize) => {
71
+ const shaped = shapeText(text, parsed.glyphForCodepoint, parsed.advanceWidths, parsed.ligatures, kerning, parsed.joiningForms);
72
+ let yMax = -Infinity;
73
+ let yMin = Infinity;
74
+ for (const gid of shaped.gids) {
75
+ const ink = glyphInk(gid);
76
+ if (!ink) continue;
77
+ if (ink.yMax > yMax) yMax = ink.yMax;
78
+ if (ink.yMin < yMin) yMin = ink.yMin;
79
+ }
80
+ if (!Number.isFinite(yMax) || !Number.isFinite(yMin)) {
81
+ yMax = parsed.capHeight || parsed.ascender;
82
+ yMin = 0;
83
+ }
84
+ const k = fontSize / parsed.unitsPerEm;
85
+ return {
86
+ above: yMax * k,
87
+ below: -yMin * k
88
+ };
89
+ };
55
90
  return {
56
91
  pdfWidthForGid,
57
92
  textWidthPt,
93
+ textInkPt,
58
94
  encodeTextAsCidHex,
59
95
  showText
60
96
  };
@@ -1,11 +1,31 @@
1
1
  /** The raster formats this module recognizes and can prepare for embedding. */
2
- export type ImageFormat = 'jpeg' | 'png' | 'jpeg2000' | 'gif' | 'tiff';
2
+ export type ImageFormat = 'jpeg' | 'png' | 'jpeg2000' | 'gif' | 'tiff' | 'bmp';
3
3
  /**
4
4
  * Sniff the raster format from a file's leading magic bytes (JPEG SOI, the PNG
5
5
  * signature, or a JP2 box / raw JPEG 2000 codestream). Returns the
6
6
  * {@link ImageFormat}, or `null` when none matches.
7
7
  */
8
8
  export declare function detectImageFormat(bytes: Uint8Array): ImageFormat | null;
9
+ /**
10
+ * The same picture with one colour knocked out of it, as a PNG.
11
+ *
12
+ * A picture may name a colour it is drawn WITHOUT — MS-ODRAW's
13
+ * `pictureTransparent`, DrawingML's `a:clrChange` to nothing — which is how
14
+ * clip art of the pre-alpha age says "this rectangle of ground is not part of
15
+ * the drawing". It is a property of the USE, not of the file, so it is baked
16
+ * here into bytes of its own: two shapes may knock different colours out of the
17
+ * same blip, and a resource store that hashes content then keeps them apart by
18
+ * itself.
19
+ *
20
+ * 23884's satellite sits on a red field and its globe on a white one; drawn as
21
+ * stored, they are a red block and a white square on a blue slide.
22
+ *
23
+ * @param bytes The picture, in any format {@link prepareImage} reads.
24
+ * @param hex The colour to knock out, 6-hex and no leading `#`.
25
+ * @returns A PNG with that colour transparent, or `undefined` when the picture
26
+ * cannot be decoded to samples this can work on.
27
+ */
28
+ export declare function knockOutColor(bytes: Uint8Array, hex: string): Uint8Array | undefined;
9
29
  /** Options controlling how {@link prepareImage} emits an image. */
10
30
  export interface EmbedImageOptions {
11
31
  /**
@@ -24,7 +44,7 @@ export interface EmbedImageOptions {
24
44
  */
25
45
  export interface PreparedImage {
26
46
  readonly format: ImageFormat;
27
- readonly mimeType: 'image/jp2' | 'image/jpeg' | 'image/png' | 'image/gif' | 'image/tiff';
47
+ readonly mimeType: 'image/jp2' | 'image/jpeg' | 'image/png' | 'image/gif' | 'image/tiff' | 'image/bmp';
28
48
  readonly widthPx: number;
29
49
  readonly heightPx: number;
30
50
  /**
@@ -38,6 +58,14 @@ export interface PreparedImage {
38
58
  readonly data: Uint8Array;
39
59
  /** PNG alpha channel, already FlateDecode-compressed (DeviceGray, 8 bpc). */
40
60
  readonly smaskData?: Uint8Array;
61
+ /**
62
+ * The resolution the picture states for itself, in pixels per inch, when it
63
+ * states one (JFIF's `Xdensity`/`Ydensity`, PNG's `pHYs`). It is what makes a
64
+ * picture's NATURAL size: 800 pixels at 300 dpi is 192 points wide, not the
65
+ * 600 the 96-dpi default would give. Absent ⇒ the reader's own default.
66
+ */
67
+ readonly dpiX?: number;
68
+ readonly dpiY?: number;
41
69
  }
42
70
  /**
43
71
  * Decode and validate one image into a {@link PreparedImage} ready to embed.
@@ -1,3 +1,5 @@
1
+ import { decodeBmp, isBmp } from "./bmp.js";
2
+ import { encodePng } from "./png-encode.js";
1
3
  import { decodeTiff, isTiff } from "./tiff.js";
2
4
  import { unzlibSync, zlibSync } from "fflate";
3
5
  //#region src/core/images.ts
@@ -13,9 +15,61 @@ function detectImageFormat(bytes) {
13
15
  if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 79 && bytes[2] === 255 && bytes[3] === 81) return "jpeg2000";
14
16
  if (bytes.length >= 6 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56 && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97) return "gif";
15
17
  if (isTiff(bytes)) return "tiff";
18
+ if (isBmp(bytes)) return "bmp";
16
19
  return null;
17
20
  }
18
21
  /**
22
+ * The same picture with one colour knocked out of it, as a PNG.
23
+ *
24
+ * A picture may name a colour it is drawn WITHOUT — MS-ODRAW's
25
+ * `pictureTransparent`, DrawingML's `a:clrChange` to nothing — which is how
26
+ * clip art of the pre-alpha age says "this rectangle of ground is not part of
27
+ * the drawing". It is a property of the USE, not of the file, so it is baked
28
+ * here into bytes of its own: two shapes may knock different colours out of the
29
+ * same blip, and a resource store that hashes content then keeps them apart by
30
+ * itself.
31
+ *
32
+ * 23884's satellite sits on a red field and its globe on a white one; drawn as
33
+ * stored, they are a red block and a white square on a blue slide.
34
+ *
35
+ * @param bytes The picture, in any format {@link prepareImage} reads.
36
+ * @param hex The colour to knock out, 6-hex and no leading `#`.
37
+ * @returns A PNG with that colour transparent, or `undefined` when the picture
38
+ * cannot be decoded to samples this can work on.
39
+ */
40
+ function knockOutColor(bytes, hex) {
41
+ let prepared;
42
+ try {
43
+ prepared = prepareImage(bytes);
44
+ } catch {
45
+ return;
46
+ }
47
+ if (prepared.filter !== "FlateDecode" || prepared.bitsPerComponent !== 8) return void 0;
48
+ const key = [
49
+ 0,
50
+ 2,
51
+ 4
52
+ ].map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
53
+ if (key.some((c) => !Number.isFinite(c))) return void 0;
54
+ const gray = prepared.colorSpace === "DeviceGray";
55
+ const src = unzlibSync(prepared.data);
56
+ const existing = prepared.smaskData ? unzlibSync(prepared.smaskData) : void 0;
57
+ const { widthPx: w, heightPx: h } = prepared;
58
+ const channels = gray ? 1 : 3;
59
+ if (src.length < w * h * channels) return void 0;
60
+ const out = new Uint8Array(w * h * 4);
61
+ for (let i = 0; i < w * h; i++) {
62
+ const r = gray ? src[i] : src[i * 3];
63
+ const g = gray ? src[i] : src[i * 3 + 1];
64
+ const b = gray ? src[i] : src[i * 3 + 2];
65
+ out[i * 4] = r;
66
+ out[i * 4 + 1] = g;
67
+ out[i * 4 + 2] = b;
68
+ out[i * 4 + 3] = r === key[0] && g === key[1] && b === key[2] ? 0 : existing?.[i] ?? 255;
69
+ }
70
+ return encodePng(w, h, "rgba", out);
71
+ }
72
+ /**
19
73
  * Decode and validate one image into a {@link PreparedImage} ready to embed.
20
74
  * JPEG and JPEG 2000 pass through verbatim (readers decode them); PNG is
21
75
  * inflated, de-filtered and re-compressed, splitting any alpha into a soft mask.
@@ -29,6 +83,7 @@ function prepareImage(bytes, options = {}) {
29
83
  if (format === "jpeg2000") return prepareJpeg2000(bytes);
30
84
  if (format === "gif") return prepareGif(bytes, options);
31
85
  if (format === "tiff") return prepareTiff(bytes, options);
86
+ if (format === "bmp") return prepareBmp(bytes, options);
32
87
  throw new Error("Unsupported image format");
33
88
  }
34
89
  function prepareJpeg2000(bytes) {
@@ -91,6 +146,7 @@ function readSiz(bytes, sizOffset) {
91
146
  }
92
147
  function prepareJpeg(bytes) {
93
148
  const info = readJpegInfo(bytes);
149
+ const density = readJfifDensity(bytes);
94
150
  return {
95
151
  format: "jpeg",
96
152
  mimeType: "image/jpeg",
@@ -99,7 +155,24 @@ function prepareJpeg(bytes) {
99
155
  colorSpace: info.numComponents === 1 ? "DeviceGray" : "DeviceRGB",
100
156
  bitsPerComponent: info.precision,
101
157
  filter: "DCTDecode",
102
- data: bytes
158
+ data: bytes,
159
+ ...density ?? {}
160
+ };
161
+ }
162
+ function readJfifDensity(bytes) {
163
+ if (bytes.length < 20 || bytes[2] !== 255 || bytes[3] !== 224) return void 0;
164
+ if (!(bytes[6] === 74 && bytes[7] === 70 && bytes[8] === 73 && bytes[9] === 70)) return void 0;
165
+ const units = bytes[13];
166
+ const x = bytes[14] << 8 | bytes[15];
167
+ const y = bytes[16] << 8 | bytes[17];
168
+ if (x <= 0 || y <= 0) return void 0;
169
+ if (units === 1) return {
170
+ dpiX: x,
171
+ dpiY: y
172
+ };
173
+ if (units === 2) return {
174
+ dpiX: x * 2.54,
175
+ dpiY: y * 2.54
103
176
  };
104
177
  }
105
178
  function readJpegInfo(bytes) {
@@ -198,6 +271,34 @@ function prepareTiff(bytes, options = {}) {
198
271
  ...alpha && !options.flattenAlpha ? { smaskData: zlibSync(alpha.slice()) } : {}
199
272
  };
200
273
  }
274
+ function prepareBmp(bytes, options = {}) {
275
+ const img = decodeBmp(bytes);
276
+ const data = img.data.slice();
277
+ const alpha = img.alpha;
278
+ if (alpha && options.flattenAlpha) for (let i = 0; i < alpha.length; i++) {
279
+ if (alpha[i] === 255) continue;
280
+ const a = alpha[i] / 255;
281
+ for (let c = 0; c < 3; c++) {
282
+ const at = i * 3 + c;
283
+ data[at] = Math.round(data[at] * a + 255 * (1 - a));
284
+ }
285
+ }
286
+ return {
287
+ format: "bmp",
288
+ mimeType: "image/bmp",
289
+ widthPx: img.width,
290
+ heightPx: img.height,
291
+ colorSpace: "DeviceRGB",
292
+ bitsPerComponent: 8,
293
+ filter: "FlateDecode",
294
+ data: zlibSync(data),
295
+ ...alpha && !options.flattenAlpha ? { smaskData: zlibSync(alpha.slice()) } : {},
296
+ ...img.dpiX !== void 0 && img.dpiY !== void 0 ? {
297
+ dpiX: img.dpiX,
298
+ dpiY: img.dpiY
299
+ } : {}
300
+ };
301
+ }
201
302
  function cmykToRgb(cmyk) {
202
303
  const out = new Uint8Array(cmyk.length / 4 * 3);
203
304
  for (let i = 0; i * 4 + 3 < cmyk.length; i++) {
@@ -356,7 +457,11 @@ function preparePng(bytes, options = {}) {
356
457
  bitsPerComponent: decoded.bitsPerComponent,
357
458
  filter: "FlateDecode",
358
459
  data: zlibSync(decoded.raw),
359
- ...decoded.smaskRaw ? { smaskData: zlibSync(decoded.smaskRaw) } : {}
460
+ ...decoded.smaskRaw ? { smaskData: zlibSync(decoded.smaskRaw) } : {},
461
+ ...decoded.dpiX !== void 0 && decoded.dpiY !== void 0 ? {
462
+ dpiX: decoded.dpiX,
463
+ dpiY: decoded.dpiY
464
+ } : {}
360
465
  };
361
466
  }
362
467
  function decodePng(bytes) {
@@ -367,6 +472,7 @@ function decodePng(bytes) {
367
472
  let interlaceMethod = 0;
368
473
  let palette;
369
474
  let paletteAlpha;
475
+ let density = {};
370
476
  const idatChunks = [];
371
477
  let pos = 8;
372
478
  while (pos + 12 <= bytes.length) {
@@ -381,7 +487,14 @@ function decodePng(bytes) {
381
487
  interlaceMethod = bytes[dataOff + 12];
382
488
  } else if (type === "PLTE") palette = bytes.subarray(dataOff, dataOff + len);
383
489
  else if (type === "tRNS") paletteAlpha = bytes.subarray(dataOff, dataOff + len);
384
- else if (type === "IDAT") idatChunks.push(bytes.subarray(dataOff, dataOff + len));
490
+ else if (type === "pHYs") {
491
+ const perX = readU32BE(bytes, dataOff);
492
+ const perY = readU32BE(bytes, dataOff + 4);
493
+ if (bytes[dataOff + 8] === 1 && perX > 0 && perY > 0) density = {
494
+ dpiX: perX * .0254,
495
+ dpiY: perY * .0254
496
+ };
497
+ } else if (type === "IDAT") idatChunks.push(bytes.subarray(dataOff, dataOff + len));
385
498
  else if (type === "IEND") break;
386
499
  pos = dataOff + len + 4;
387
500
  }
@@ -397,7 +510,10 @@ function decodePng(bytes) {
397
510
  const channels = pngChannels(colorType);
398
511
  if (channels === 0) throw new Error(`PNG color type ${colorType} not supported`);
399
512
  const raw = decodeSamples(unzlibSync(concatBytes(idatChunks)), width, height, channels, bitDepth, interlaceMethod === 1, colorType !== 3);
400
- return splitChannels(width, height, colorType, raw, palette, paletteAlpha);
513
+ return {
514
+ ...splitChannels(width, height, colorType, raw, palette, paletteAlpha),
515
+ ...density
516
+ };
401
517
  }
402
518
  /**
403
519
  * §7.2 / §9 — the image's samples, one byte each, in `width × height ×
@@ -665,4 +781,4 @@ function concatBytes(parts) {
665
781
  return out;
666
782
  }
667
783
  //#endregion
668
- export { detectImageFormat, prepareImage };
784
+ export { detectImageFormat, knockOutColor, prepareImage };