@pollium/vyva-convert 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @pollium/vyva-convert
2
2
 
3
3
  Server-side tools for Vyva content. It cleans what clients send, imports Word, PowerPoint, PDF,
4
- Markdown and text files, and exports to PDF, Word, PowerPoint and HTML. It runs in Node.
4
+ OpenDocument, RTF, Markdown, text and image files, and exports to PDF, Word, PowerPoint and HTML.
5
+ It runs in Node.
5
6
 
6
7
  ```sh
7
8
  npm install @pollium/vyva-convert
@@ -66,12 +67,33 @@ const { title, content } = await importFile(upload.bytes, upload.filename, {
66
67
  });
67
68
  ```
68
69
 
69
- - The file is recognized by its content, not its name. Word and PowerPoint files are told apart
70
- by what their zip holds, and PDF by its signature. Only Markdown (`.md`) and text (`.txt`) are
71
- recognized by their extension, and only when they are valid UTF-8.
70
+ - The file is recognized by its content, not its name. Word, PowerPoint and OpenDocument files
71
+ are told apart by what their zip holds, Word 97 and PowerPoint 97 files by the streams they hold,
72
+ and PDF, RTF, PNG, JPEG, GIF and WebP by their signatures. Only Markdown (`.md`) and text
73
+ (`.txt`) are recognized by their extension, and only when they are valid UTF-8.
72
74
  - Word, PDF, Markdown and text files become documents, and PowerPoint files become decks. A deck
73
- keeps its theme colors and font. What its slide master draws comes in locked, behind the slide's
74
- own elements.
75
+ keeps its theme colors and the font most of its text uses. What its slide master draws comes in
76
+ locked, behind the slide's own elements, and freeform shapes come in as polygons.
77
+ - A picture becomes a deck of one slide, in the format closest to its shape, with the picture
78
+ filling it. `saveImage` stores it like any other image; return `null` and the import is refused
79
+ as `empty`.
80
+ - With `officeEngine`, Vyva reads what it cannot read alone. Every PDF becomes a deck: each page a
81
+ slide, portrait pages on a portrait slide, with its pictures and shapes where they were, and the
82
+ loose lines a PDF keeps joined back into paragraphs and lists. A PDF of more than 300 pages is
83
+ refused as `too-large` before the engine runs. Word 97, OpenDocument text and RTF files are
84
+ turned into Word, and PowerPoint 97 and OpenDocument presentations into PowerPoint, then imported
85
+ as those. Without an engine a PDF becomes a document of its text, and the older formats are
86
+ refused as `unsupported`.
87
+
88
+ `officeEngine` takes `{ file, from, to }` and returns the converted file. `libreOffice()` from
89
+ `@pollium/vyva-convert/libreoffice` does that with the `soffice` on the machine (`command` changes
90
+ the path), one conversion per call:
91
+
92
+ ```ts
93
+ import { libreOffice } from '@pollium/vyva-convert/libreoffice';
94
+
95
+ await importFile(upload.bytes, upload.filename, { saveImage, officeEngine: libreOffice() });
96
+ ```
75
97
  - `saveImage` stores each image and returns its file id, which must be a UUID. Return `null` to
76
98
  leave the image out. If it throws, the import fails with your error.
77
99
  - Files over 25 MB (`maxBytes` changes the limit) and files it cannot read are refused.
@@ -87,3 +109,4 @@ a bug or a broken file, so log it.
87
109
  | `too-large` | The file, or what it unzips to, is over the limit |
88
110
  | `empty` | The file has nothing to import |
89
111
  | `pdf-failed` | The PDF engine could not be reached or answered with an error |
112
+ | `office-failed` | `officeEngine` could not convert the file, or answered with something else |
@@ -28,7 +28,12 @@ const cleanElement = (element, font) => {
28
28
  html
29
29
  };
30
30
  }
31
- if (element.type !== "shape" || element.text === void 0) return element;
31
+ if (element.type !== "shape") return element;
32
+ if (element.points !== void 0 && element.shape !== "polygon") {
33
+ const { points: _points, ...plain } = element;
34
+ return cleanElement(plain, font);
35
+ }
36
+ if (element.text === void 0) return element;
32
37
  const { text, ...bare } = element;
33
38
  const html = TEXT_SHAPES.includes(element.shape) ? cleanHtml(text.html) : "";
34
39
  return html === "" ? bare : {
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region src/errors.d.ts
2
- export type ConvertErrorCode = 'too-large' | 'unsupported' | 'empty' | 'pdf-failed';
2
+ export type ConvertErrorCode = 'too-large' | 'unsupported' | 'empty' | 'pdf-failed' | 'office-failed';
3
3
  export declare class ConvertError extends Error {
4
4
  readonly code: ConvertErrorCode;
5
5
  constructor(code: ConvertErrorCode, message: string, options?: ErrorOptions);
@@ -110,8 +110,31 @@ const SHAPES = {
110
110
  ellipse: "ellipse",
111
111
  triangle: "triangle",
112
112
  line: "line",
113
- arrow: "line"
113
+ arrow: "line",
114
+ polygon: "custGeom"
114
115
  };
116
+ const BOX_CORNERS = [
117
+ {
118
+ x: 0,
119
+ y: 0
120
+ },
121
+ {
122
+ x: 1,
123
+ y: 0
124
+ },
125
+ {
126
+ x: 1,
127
+ y: 1
128
+ },
129
+ {
130
+ x: 0,
131
+ y: 1
132
+ }
133
+ ];
134
+ const outline = (element) => [...(element.points ?? BOX_CORNERS).map((point) => ({
135
+ x: inch(point.x * element.w),
136
+ y: inch(point.y * element.h)
137
+ })), { close: true }];
115
138
  const addShape = (slide, element, deck) => {
116
139
  const isLine = element.shape === "line" || element.shape === "arrow";
117
140
  const lineColor = element.stroke ?? element.fill;
@@ -132,7 +155,8 @@ const addShape = (slide, element, deck) => {
132
155
  width: Math.max(.5, element.strokeWidth * PT_PER_PX),
133
156
  ...element.shape === "arrow" ? { endArrowType: "triangle" } : {}
134
157
  } },
135
- ...element.shape === "rounded" ? { rectRadius: .18 } : {}
158
+ ...element.shape === "rounded" ? { rectRadius: .18 } : {},
159
+ ...element.shape === "polygon" ? { points: outline(element) } : {}
136
160
  };
137
161
  if (element.text !== void 0 && TEXT_SHAPES.includes(element.shape) && element.text.html.trim() !== "") {
138
162
  slide.addText(textRuns(element.text.html, element.text.style), {
@@ -0,0 +1,4 @@
1
+ import { DeckFormat } from "@pollium/vyva-core/content";
2
+ //#region src/import/deck-format.d.ts
3
+ export declare const formatFor: (width: number, height: number) => DeckFormat;
4
+ //#endregion
@@ -0,0 +1,12 @@
1
+ import { DECK_SIZES } from "@pollium/vyva-core/content";
2
+ //#region src/import/deck-format.ts
3
+ const FORMATS = Object.entries(DECK_SIZES).map(([format, size]) => ({
4
+ format,
5
+ ratio: size.width / size.height
6
+ }));
7
+ const formatFor = (width, height) => {
8
+ const ratio = width / Math.max(height, 1);
9
+ return FORMATS.reduce((best, candidate) => Math.abs(candidate.ratio - ratio) < Math.abs(best.ratio - ratio) ? candidate : best).format;
10
+ };
11
+ //#endregion
12
+ export { formatFor };
@@ -0,0 +1,7 @@
1
+ import { SaveImage } from "./images.js";
2
+ import { ImportedDeck } from "./pptx.js";
3
+ //#region src/import/image.d.ts
4
+ export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
5
+ export declare const imageTypeOf: (data: Buffer) => ImageType | null;
6
+ export declare const importImage: (buffer: Buffer, filename: string, contentType: ImageType, saveImage: SaveImage) => Promise<ImportedDeck>;
7
+ //#endregion
@@ -0,0 +1,59 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { imageSize } from "../export/image-size.js";
3
+ import { titleOf } from "./titles.js";
4
+ import { imageSaver } from "./images.js";
5
+ import { formatFor } from "./deck-format.js";
6
+ import { DECK_SIZES } from "@pollium/vyva-core/content";
7
+ import { newId } from "@pollium/vyva-core/ids";
8
+ import { DECK_THEME } from "@pollium/vyva-core/themes";
9
+ //#region src/import/image.ts
10
+ const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
11
+ const JPEG_SIGNATURE = Buffer.from("ffd8ff", "hex");
12
+ const imageTypeOf = (data) => {
13
+ if (data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) return "image/png";
14
+ if (data.subarray(0, JPEG_SIGNATURE.length).equals(JPEG_SIGNATURE)) return "image/jpeg";
15
+ const head = data.toString("latin1", 0, 12);
16
+ if (head.startsWith("GIF87a") || head.startsWith("GIF89a")) return "image/gif";
17
+ return head.startsWith("RIFF") && head.slice(8) === "WEBP" ? "image/webp" : null;
18
+ };
19
+ const importImage = async (buffer, filename, contentType, saveImage) => {
20
+ const fileId = await imageSaver(saveImage, filename, 1)(buffer, contentType);
21
+ if (fileId === null) throw new ConvertError("empty", `${filename} was not stored, so there is nothing to show`);
22
+ const size = imageSize(buffer, contentType);
23
+ const format = formatFor(size.width, size.height);
24
+ const slide = DECK_SIZES[format];
25
+ const scale = Math.min(slide.width / size.width, slide.height / size.height);
26
+ const w = Math.round(size.width * scale);
27
+ const h = Math.round(size.height * scale);
28
+ const { frame: _frame, ...theme } = DECK_THEME;
29
+ return {
30
+ title: titleOf("", filename),
31
+ content: {
32
+ kind: "deck",
33
+ format,
34
+ theme,
35
+ slides: [{
36
+ id: newId(),
37
+ background: null,
38
+ elements: [{
39
+ id: newId(),
40
+ type: "image",
41
+ x: Math.round((slide.width - w) / 2),
42
+ y: Math.round((slide.height - h) / 2),
43
+ w,
44
+ h,
45
+ rotation: 0,
46
+ opacity: 1,
47
+ isLocked: false,
48
+ fileId,
49
+ fit: "contain",
50
+ radius: 0,
51
+ alt: "",
52
+ credit: null
53
+ }]
54
+ }]
55
+ }
56
+ };
57
+ };
58
+ //#endregion
59
+ export { imageTypeOf, importImage };
@@ -0,0 +1,10 @@
1
+ //#region src/import/office.d.ts
2
+ export type LegacyOffice = 'doc' | 'ppt' | 'odt' | 'odp' | 'rtf';
3
+ export interface OfficeJob {
4
+ file: Uint8Array;
5
+ from: 'pdf' | LegacyOffice;
6
+ to: 'docx' | 'pptx';
7
+ }
8
+ export type OfficeEngine = (job: OfficeJob) => Promise<Uint8Array>;
9
+ export declare const runOffice: (engine: OfficeEngine, job: OfficeJob, filename: string) => Promise<Buffer>;
10
+ //#endregion
@@ -0,0 +1,10 @@
1
+ import { ConvertError } from "../errors.js";
2
+ //#region src/import/office.ts
3
+ const runOffice = async (engine, job, filename) => {
4
+ const converted = await engine(job).catch((error) => {
5
+ throw error instanceof ConvertError ? error : new ConvertError("office-failed", `${filename} could not be turned into ${job.to}`, { cause: error });
6
+ });
7
+ return Buffer.from(converted);
8
+ };
9
+ //#endregion
10
+ export { runOffice };
@@ -0,0 +1,4 @@
1
+ import { DeckContent } from "@pollium/vyva-core/content";
2
+ //#region src/import/pdf-lines.d.ts
3
+ export declare const joinLines: (deck: DeckContent) => DeckContent;
4
+ //#endregion
@@ -0,0 +1,206 @@
1
+ import { textHeight, unwrappedWidth } from "../text-measure.js";
2
+ import { DECK_SIZES } from "@pollium/vyva-core/content";
3
+ import { newId } from "@pollium/vyva-core/ids";
4
+ //#region src/import/pdf-lines.ts
5
+ const BULLET = /^[•▪◦●○■□➢►▸✓✔\-–*]$/;
6
+ const NUMBER = /^(\d{1,3}|[a-zA-Z])[.)]$/;
7
+ const LEADING_BULLET = /^((?:<[^>]+>)*)\s*[•▪◦●○■□➢►▸✓✔\-–*]\s+(?=\S)/u;
8
+ const LEADING_NUMBER = /^((?:<[^>]+>)*)\s*(?:\d{1,3}|[a-z])[.)]\s+(?=\S)/u;
9
+ const MAX_CONTAINER_SHARE = .5;
10
+ const CONTAINER_SLACK = 2;
11
+ const PARAGRAPH = /^<p(?: style="[^"]*")?>([\s\S]*)<\/p>$/;
12
+ const MARKER_REACH_EMS = 3;
13
+ const SAME_EDGE_EMS = .5;
14
+ const SAME_SIZE = .1;
15
+ const MAX_LINE_GAP = 1;
16
+ const PARAGRAPH_GAP = .3;
17
+ const LIST_INDENT_EMS = 1.15;
18
+ const SLIDE_MARGIN = 16;
19
+ const SIDE_SHARE = .3;
20
+ const SPACED_END = /\s(?:<\/[^>]+>)*$/;
21
+ const plain = (html) => html.replace(/<[^>]+>/g, "").replace(/&nbsp;/g, " ").trim();
22
+ const middle = (element) => element.y + element.h / 2;
23
+ const right = (element) => element.x + element.w;
24
+ const center = (element) => element.x + element.w / 2;
25
+ const area = (element) => element.w * element.h;
26
+ const containerOf = (element, boxes) => boxes.filter((box) => box.x - CONTAINER_SLACK <= element.x && box.y - CONTAINER_SLACK <= element.y && box.x + box.w + CONTAINER_SLACK >= right(element) && box.y + box.h + CONTAINER_SLACK >= element.y + element.h).sort((a, b) => area(a) - area(b))[0] ?? null;
27
+ const lineOf = (element, boxes) => {
28
+ if (element.type !== "text" || element.isLocked || element.rotation !== 0 || element.fill !== null) return null;
29
+ const html = PARAGRAPH.exec(element.html)?.[1];
30
+ if (html === void 0 || html.includes("<p")) return null;
31
+ const marker = LEADING_BULLET.test(html) ? "bullet" : LEADING_NUMBER.test(html) ? "number" : null;
32
+ const inner = marker === null ? html : html.replace(marker === "bullet" ? LEADING_BULLET : LEADING_NUMBER, "$1");
33
+ return {
34
+ element,
35
+ inner,
36
+ text: plain(inner),
37
+ marker,
38
+ left: element.x,
39
+ container: containerOf(element, boxes)
40
+ };
41
+ };
42
+ const markerOf = (line) => BULLET.test(line.text) ? "bullet" : NUMBER.test(line.text) ? "number" : null;
43
+ const ownerOf = (marker, lines) => lines.find((line) => {
44
+ const { element } = line;
45
+ const reach = MARKER_REACH_EMS * element.style.fontSize;
46
+ const gap = element.x - right(marker.element);
47
+ return line.marker === null && markerOf(line) === null && gap > -element.style.fontSize && gap < reach && Math.abs(middle(element) - middle(marker.element)) < element.h * .6;
48
+ });
49
+ const isSameSize = (a, b) => Math.abs(a.fontSize - b.fontSize) <= Math.max(a.fontSize, b.fontSize) * SAME_SIZE;
50
+ const alignWith = (block, line) => {
51
+ const last = block.lines.at(-1)?.element;
52
+ if (last === void 0) return null;
53
+ const { element } = line;
54
+ const edge = SAME_EDGE_EMS * element.style.fontSize;
55
+ const gap = element.y - (last.y + last.h);
56
+ if (line.container !== block.lines[0].container) return null;
57
+ if (!isSameSize(last.style, element.style) || element.y <= last.y || gap > last.h * MAX_LINE_GAP || gap < -last.h * .5) return null;
58
+ if (gap > last.h * PARAGRAPH_GAP && line.marker === null !== (block.lines[0].marker === null)) return null;
59
+ if (Math.abs(element.x - block.lines[0].element.x) < edge) return block.lines.length === 1 || block.align === "left" ? "left" : null;
60
+ if (line.marker === null && Math.abs(center(element) - center(last)) < edge) return block.lines.length === 1 || block.align === "center" ? "center" : null;
61
+ return null;
62
+ };
63
+ const joined = (lines) => lines.reduce((html, line, index) => {
64
+ if (index === 0) return line.inner;
65
+ const previous = lines[index - 1].text;
66
+ if (/\p{L}-$/u.test(previous) && /^\p{Ll}/u.test(line.text)) return `${html.replace(/-((?:<\/[^>]+>)*)$/, "$1")}${line.inner}`;
67
+ return SPACED_END.test(html) || /^\s/.test(line.inner) ? `${html}${line.inner}` : `${html} ${line.inner}`;
68
+ }, "");
69
+ const styled = (line, base) => {
70
+ const { style } = line.element;
71
+ return style.color.toLowerCase() === base.color.toLowerCase() ? line : {
72
+ ...line,
73
+ inner: `<span style="color:${style.color}">${line.inner}</span>`
74
+ };
75
+ };
76
+ const paragraphsOf = (lines) => lines.reduce((paragraphs, line, index) => {
77
+ const previous = lines[index - 1]?.element;
78
+ const current = paragraphs.at(-1);
79
+ if (current === void 0 || previous === void 0 || line.marker !== null || line.element.y - (previous.y + previous.h) > previous.h * PARAGRAPH_GAP || current === void 0) paragraphs.push([line]);
80
+ else current.push(line);
81
+ return paragraphs;
82
+ }, []);
83
+ const listTag = (marker) => marker === "bullet" ? "ul" : "ol";
84
+ const htmlOf = (paragraphs) => {
85
+ let html = "";
86
+ let open = null;
87
+ for (const paragraph of paragraphs) {
88
+ const marker = paragraph[0].marker;
89
+ if (open !== null && open !== marker) {
90
+ html += `</${listTag(open)}>`;
91
+ open = null;
92
+ }
93
+ if (marker === null) {
94
+ html += `<p>${joined(paragraph)}</p>`;
95
+ continue;
96
+ }
97
+ if (open === null) html += `<${listTag(marker)}>`;
98
+ open = marker;
99
+ html += `<li>${joined(paragraph)}</li>`;
100
+ }
101
+ return open === null ? html : `${html}</${listTag(open)}>`;
102
+ };
103
+ const lineHeightOf = (lines, fontSize, fallback) => {
104
+ const pitches = lines.slice(1).map((line, index) => line.element.y - lines[index].element.y).filter((pitch) => pitch < fontSize * 3);
105
+ if (pitches.length === 0) return fallback;
106
+ const sorted = pitches.sort((a, b) => a - b);
107
+ return Math.min(3, Math.max(.8, Number((sorted[Math.floor(sorted.length / 2)] / fontSize).toFixed(2))));
108
+ };
109
+ const merged = (block, neighbours, font, slideWidth) => {
110
+ const [first] = block.lines;
111
+ const base = {
112
+ ...first.element.style,
113
+ isBold: block.lines.every((line) => line.element.style.isBold),
114
+ align: block.align,
115
+ verticalAlign: "top"
116
+ };
117
+ const lines = block.lines.map((line) => styled(line, base));
118
+ const paragraphs = paragraphsOf(lines);
119
+ const html = htmlOf(paragraphs);
120
+ const indent = LIST_INDENT_EMS * base.fontSize;
121
+ const left = Math.max(0, Math.min(...block.lines.map((line) => line.left)));
122
+ const last = block.lines[block.lines.length - 1].element;
123
+ const wanted = Math.max(...block.lines.map((line) => Math.max(right(line.element), left + (line.marker === null ? 0 : indent) + unwrappedWidth(`<p>${line.inner}</p>`, base, font)))) - left;
124
+ const limit = Math.min(slideWidth - SLIDE_MARGIN, ...neighbours.filter((element) => isInTheWay(element, left, wanted, first.element.y, last.y + last.h)).map((element) => element.x - SLIDE_MARGIN));
125
+ const w = Math.max(1, Math.round(Math.min(wanted, limit - left)));
126
+ const style = {
127
+ ...base,
128
+ lineHeight: lineHeightOf(block.lines, base.fontSize, base.lineHeight)
129
+ };
130
+ const height = textHeight(html, style, font, w);
131
+ return {
132
+ id: newId(),
133
+ type: "text",
134
+ x: Math.round(left),
135
+ y: first.element.y,
136
+ w,
137
+ h: Math.max(1, Math.round(Number.isFinite(height) ? height : first.element.h * block.lines.length)),
138
+ rotation: 0,
139
+ opacity: first.element.opacity,
140
+ isLocked: false,
141
+ fill: null,
142
+ html,
143
+ style
144
+ };
145
+ };
146
+ const isInTheWay = (element, left, width, top, bottom) => element.x > left + width * SIDE_SHARE && element.x < left + width && element.y < bottom && element.y + element.h > top;
147
+ const blocksOf = (lines) => {
148
+ const blocks = [];
149
+ const ordered = [...lines].sort((a, b) => a.element.y - b.element.y || a.element.x - b.element.x);
150
+ for (const line of ordered) {
151
+ const match = blocks.map((block) => ({
152
+ block,
153
+ align: alignWith(block, line)
154
+ })).find(({ align }) => align !== null);
155
+ if (match === void 0 || match.align === null) {
156
+ blocks.push({
157
+ lines: [line],
158
+ align: "left"
159
+ });
160
+ continue;
161
+ }
162
+ match.block.lines.push(line);
163
+ match.block.align = match.align;
164
+ }
165
+ return blocks;
166
+ };
167
+ const joinSlide = (slide, font, slideWidth, slideHeight) => {
168
+ const boxes = slide.elements.filter((element) => element.type === "shape" && element.fill !== null && element.shape !== "line" && area(element) < slideWidth * slideHeight * MAX_CONTAINER_SHARE);
169
+ const lines = slide.elements.map((element) => lineOf(element, boxes)).filter((line) => line !== null);
170
+ const markers = lines.filter((line) => markerOf(line) !== null);
171
+ const owned = /* @__PURE__ */ new Set();
172
+ for (const marker of markers) {
173
+ const owner = ownerOf(marker, lines);
174
+ if (owner === void 0) continue;
175
+ owner.marker = markerOf(marker);
176
+ owner.left = marker.element.x;
177
+ owned.add(marker);
178
+ }
179
+ const blocks = blocksOf(lines.filter((line) => !owned.has(line)));
180
+ const replaced = /* @__PURE__ */ new Map();
181
+ for (const block of blocks) {
182
+ if (block.lines.length === 1 && block.lines[0].marker === null && right(block.lines[0].element) <= slideWidth - SLIDE_MARGIN) continue;
183
+ const [head, ...rest] = block.lines;
184
+ const own = /* @__PURE__ */ new Set([...block.lines.map((line) => line.element), ...[...owned].map((marker) => marker.element)]);
185
+ replaced.set(head.element, merged(block, slide.elements.filter((element) => !own.has(element)), font, slideWidth));
186
+ for (const line of rest) replaced.set(line.element, null);
187
+ }
188
+ for (const marker of owned) replaced.set(marker.element, null);
189
+ const elements = slide.elements.flatMap((element) => {
190
+ const replacement = replaced.get(element);
191
+ return replacement === void 0 ? [element] : replacement === null ? [] : [replacement];
192
+ });
193
+ return {
194
+ ...slide,
195
+ elements
196
+ };
197
+ };
198
+ const joinLines = (deck) => {
199
+ const { width, height } = DECK_SIZES[deck.format];
200
+ return {
201
+ ...deck,
202
+ slides: deck.slides.map((slide) => joinSlide(slide, deck.theme.font, width, height))
203
+ };
204
+ };
205
+ //#endregion
206
+ export { joinLines };
@@ -0,0 +1,6 @@
1
+ import { SaveImage } from "./images.js";
2
+ import { OfficeEngine } from "./office.js";
3
+ import { ImportedDeck } from "./pptx.js";
4
+ //#region src/import/pdf-slides.d.ts
5
+ export declare const importPdfSlides: (buffer: Buffer, filename: string, saveImage: SaveImage, officeEngine: OfficeEngine) => Promise<ImportedDeck>;
6
+ //#endregion
@@ -0,0 +1,22 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { runOffice } from "./office.js";
3
+ import { joinLines } from "./pdf-lines.js";
4
+ import { importPptx } from "./pptx.js";
5
+ import { getDocumentProxy } from "unpdf";
6
+ //#region src/import/pdf-slides.ts
7
+ const importPdfSlides = async (buffer, filename, saveImage, officeEngine) => {
8
+ const { numPages } = await getDocumentProxy(new Uint8Array(buffer));
9
+ if (numPages > 300) throw new ConvertError("too-large", `${filename} has ${numPages} pages, more than the 300 slides a deck holds`);
10
+ const pptx = await runOffice(officeEngine, {
11
+ file: buffer,
12
+ from: "pdf",
13
+ to: "pptx"
14
+ }, filename);
15
+ const { title, content } = await importPptx(pptx, filename, saveImage);
16
+ return {
17
+ title,
18
+ content: joinLines(content)
19
+ };
20
+ };
21
+ //#endregion
22
+ export { importPdfSlides };
@@ -0,0 +1,11 @@
1
+ import { XmlElement } from "./pptx-xml.js";
2
+ import { ShapePoint } from "@pollium/vyva-core/content";
3
+ //#region src/import/pptx-geometry.d.ts
4
+ export type Outline = {
5
+ kind: 'rect';
6
+ } | {
7
+ kind: 'polygon';
8
+ points: ShapePoint[];
9
+ };
10
+ export declare const outlineOf: (geometry: XmlElement, xfrm: XmlElement | undefined) => Outline | null;
11
+ //#endregion
@@ -0,0 +1,74 @@
1
+ import { all, attr, num, tags } from "./pptx-xml.js";
2
+ import { MAX_SHAPE_POINTS } from "@pollium/vyva-core/content";
3
+ //#region src/import/pptx-geometry.ts
4
+ const CURVE_STEPS = [
5
+ .25,
6
+ .5,
7
+ .75,
8
+ 1
9
+ ];
10
+ const SAME = .001;
11
+ const cubic = (from, [c1, c2, to], t) => {
12
+ const u = 1 - t;
13
+ const at = (axis) => u * u * u * from[axis] + 3 * u * u * t * c1[axis] + 3 * u * t * t * c2[axis] + t * t * t * to[axis];
14
+ return [at(0), at(1)];
15
+ };
16
+ const quadratic = (from, [control, to], t) => {
17
+ const u = 1 - t;
18
+ const at = (axis) => u * u * from[axis] + 2 * u * t * control[axis] + t * t * to[axis];
19
+ return [at(0), at(1)];
20
+ };
21
+ const pointsOf = (step) => all(step, "a:pt").map((point) => [num(attr(point, "x")), num(attr(point, "y"))]);
22
+ const traced = (path) => {
23
+ const points = [];
24
+ let moves = 0;
25
+ for (const step of tags(path)) {
26
+ const given = pointsOf(step);
27
+ const from = points.at(-1) ?? [0, 0];
28
+ if (step.name === "a:moveTo") moves += 1;
29
+ if (step.name === "a:moveTo" || step.name === "a:lnTo") points.push(...given.slice(-1));
30
+ else if (step.name === "a:cubicBezTo" && given.length === 3) points.push(...CURVE_STEPS.map((t) => cubic(from, given, t)));
31
+ else if (step.name === "a:quadBezTo" && given.length === 2) points.push(...CURVE_STEPS.map((t) => quadratic(from, given, t)));
32
+ else if (step.name !== "a:close") return null;
33
+ }
34
+ return moves === 1 ? points : null;
35
+ };
36
+ const isBox = (points) => points.length === 4 && points.every((point) => [point.x, point.y].every((value) => value < SAME || value > .999));
37
+ const thinned = (points) => {
38
+ const distinct = points.filter((point, index) => {
39
+ const previous = points[index - 1];
40
+ return previous === void 0 || Math.abs(point.x - previous.x) > SAME || Math.abs(point.y - previous.y) > SAME;
41
+ });
42
+ const [head] = distinct;
43
+ const tail = distinct.at(-1);
44
+ const open = distinct.length > 1 && head !== void 0 && tail !== void 0 && Math.abs(head.x - tail.x) < SAME && Math.abs(head.y - tail.y) < SAME ? distinct.slice(0, -1) : distinct;
45
+ if (open.length <= MAX_SHAPE_POINTS) return open;
46
+ const stride = open.length / MAX_SHAPE_POINTS;
47
+ return Array.from({ length: MAX_SHAPE_POINTS }, (_, index) => open[Math.floor(index * stride)]);
48
+ };
49
+ const outlineOf = (geometry, xfrm) => {
50
+ const paths = all(geometry, "a:path");
51
+ const [path] = paths;
52
+ if (paths.length !== 1 || path === void 0) return null;
53
+ const w = num(attr(path, "w"));
54
+ const h = num(attr(path, "h"));
55
+ const steps = traced(path);
56
+ if (w <= 0 || h <= 0 || steps === null) return null;
57
+ const flipX = attr(xfrm, "flipH") === "1";
58
+ const flipY = attr(xfrm, "flipV") === "1";
59
+ const unit = (value, extent, isFlipped) => {
60
+ const share = Math.min(1, Math.max(0, value / extent));
61
+ return Number((isFlipped ? 1 - share : share).toFixed(4));
62
+ };
63
+ const points = thinned(steps.map(([x, y]) => ({
64
+ x: unit(x, w, flipX),
65
+ y: unit(y, h, flipY)
66
+ })));
67
+ if (points.length < 3) return null;
68
+ return isBox(points) ? { kind: "rect" } : {
69
+ kind: "polygon",
70
+ points
71
+ };
72
+ };
73
+ //#endregion
74
+ export { outlineOf };
@@ -1,6 +1,8 @@
1
1
  import { cleanHtml } from "../clean-html.js";
2
+ import { unwrappedWidth } from "../text-measure.js";
2
3
  import { all, attr, first, kid, kids, num, tags, textOf } from "./pptx-xml.js";
3
4
  import { TITLE_TYPES, bulletsByDefault, decorOf, isDefined, levelOf, masterLevel, placeholderChain } from "./pptx-placeholders.js";
5
+ import { outlineOf } from "./pptx-geometry.js";
4
6
  import { MAX_CELL_TEXT, MAX_TABLE_COLUMNS, MAX_TABLE_ROWS } from "@pollium/vyva-core/content";
5
7
  import { newId } from "@pollium/vyva-core/ids";
6
8
  import { escapeHtml } from "@pollium/vyva-core/escape";
@@ -120,6 +122,21 @@ var SlideReader = class {
120
122
  isLocked
121
123
  };
122
124
  }
125
+ #unwrapped(frame, html, style) {
126
+ const w = unwrappedWidth(html, style, this.#options.theme.font);
127
+ if (w <= frame.w) return frame;
128
+ const shift = {
129
+ left: 0,
130
+ justify: 0,
131
+ center: (w - frame.w) / 2,
132
+ right: w - frame.w
133
+ }[style.align];
134
+ return {
135
+ ...frame,
136
+ x: Math.round(frame.x - shift),
137
+ w
138
+ };
139
+ }
123
140
  #pixels(size) {
124
141
  const hundredths = num(size);
125
142
  return hundredths > 0 ? Math.round(hundredths / 100 * PX_PER_POINT * this.#options.fontScale) : null;
@@ -174,11 +191,7 @@ var SlideReader = class {
174
191
  const palette = visit.part.palette;
175
192
  const firstRun = all(body, "a:r").map((run) => kid(run, "a:rPr")).find(isDefined);
176
193
  const firstAlign = ALIGNS[attr(kid(kids(body, "a:p")[0], "a:pPr"), "algn") ?? ""] ?? null;
177
- const look = mergeLooks([
178
- {
179
- ...this.#runLook(firstRun, palette),
180
- align: firstAlign
181
- },
194
+ const inherited = mergeLooks([
182
195
  ...chain.map((shape) => this.#levelLook(levelOf(shape), palette)),
183
196
  this.#levelLook(masterLevel(visit.parts, type), palette),
184
197
  {
@@ -186,6 +199,14 @@ var SlideReader = class {
186
199
  color: styleColor(chain[0], "a:fontRef", palette)
187
200
  }
188
201
  ]);
202
+ const look = mergeLooks([{
203
+ ...this.#runLook(firstRun, palette),
204
+ align: firstAlign
205
+ }, inherited]);
206
+ const isBold = all(body, "a:r").every((run) => {
207
+ const bold = attr(kid(run, "a:rPr"), "b");
208
+ return bold === void 0 ? inherited.isBold === true : bold === "1";
209
+ });
189
210
  const isTitle = TITLE_TYPES.includes(type ?? "");
190
211
  const anchor = chain.map((shape) => attr(kid(kid(shape, "p:txBody"), "a:bodyPr"), "anchor")).find(isDefined);
191
212
  const { theme } = this.#options;
@@ -195,7 +216,7 @@ var SlideReader = class {
195
216
  align: look.align ?? "left",
196
217
  verticalAlign: anchor === "ctr" ? "middle" : anchor === "b" ? "bottom" : "top",
197
218
  lineHeight: 1.2,
198
- isBold: look.isBold ?? false
219
+ isBold
199
220
  };
200
221
  }
201
222
  #shapeInto(visit, shape, group) {
@@ -238,7 +259,7 @@ var SlideReader = class {
238
259
  }
239
260
  };
240
261
  return {
241
- ...frame,
262
+ ...chain.map((link) => attr(kid(kid(link, "p:txBody"), "a:bodyPr"), "wrap")).find(isDefined) === "none" ? this.#unwrapped(frame, html, style) : frame,
242
263
  type: "text",
243
264
  html,
244
265
  fill,
@@ -246,13 +267,16 @@ var SlideReader = class {
246
267
  };
247
268
  }
248
269
  if (fill === null && stroke === null) return null;
270
+ const geometry = chain.map((link) => first(link, "a:custGeom")).find(isDefined);
271
+ const outline = geometry === void 0 ? null : outlineOf(geometry, kid(props[0], "a:xfrm"));
249
272
  return {
250
273
  ...frame,
251
274
  type: "shape",
252
- shape: line !== void 0 && first(line, "a:tailEnd") !== void 0 && kind === "line" ? "arrow" : kind,
275
+ shape: line !== void 0 && first(line, "a:tailEnd") !== void 0 && kind === "line" ? "arrow" : outline?.kind ?? kind,
253
276
  fill: kind === "line" ? null : fill,
254
277
  stroke,
255
- strokeWidth
278
+ strokeWidth,
279
+ ...outline?.kind === "polygon" ? { points: outline.points } : {}
256
280
  };
257
281
  }
258
282
  async #pictureInto(visit, node, group) {
@@ -1,6 +1,7 @@
1
1
  import { XmlDocument, XmlElement } from "./pptx-xml.js";
2
- import { DeckTheme } from "@pollium/vyva-core/content";
2
+ import { DeckTheme, FontFamily } from "@pollium/vyva-core/content";
3
3
  //#region src/import/pptx-theme.d.ts
4
+ export declare const familyOf: (name: string | null) => FontFamily | null;
4
5
  export interface PptxTheme {
5
6
  colors: Record<string, string>;
6
7
  bodyFont: string | null;
@@ -42,6 +42,10 @@ const FONT_SUBSTITUTES = {
42
42
  "courier new": "JetBrains Mono",
43
43
  consolas: "JetBrains Mono"
44
44
  };
45
+ const familyOf = (name) => {
46
+ if (name === null) return null;
47
+ return FONT_FAMILIES.find((font) => font.toLowerCase() === name.toLowerCase()) ?? FONT_SUBSTITUTES[name.toLowerCase()] ?? null;
48
+ };
45
49
  const rgbOf = (hex) => {
46
50
  const channel = (at) => Number.parseInt(hex.slice(at, at + 2), 16) / 255;
47
51
  return [
@@ -133,11 +137,6 @@ var Palette = class {
133
137
  const stop = gradient === void 0 ? void 0 : first(gradient, "a:gs");
134
138
  return stop === void 0 ? null : this.color(stop);
135
139
  }
136
- #font() {
137
- const name = this.#theme.bodyFont;
138
- if (name === null) return null;
139
- return FONT_FAMILIES.find((font) => font.toLowerCase() === name.toLowerCase()) ?? FONT_SUBSTITUTES[name.toLowerCase()] ?? null;
140
- }
141
140
  deckTheme() {
142
141
  const fallback = DECK_THEME;
143
142
  const slot = (name) => {
@@ -145,7 +144,7 @@ var Palette = class {
145
144
  return value === null ? null : `#${value}`;
146
145
  };
147
146
  return {
148
- font: this.#font() ?? fallback.font,
147
+ font: familyOf(this.#theme.bodyFont) ?? fallback.font,
149
148
  background: slot("bg1") ?? fallback.background,
150
149
  text: slot("tx1") ?? fallback.text,
151
150
  accent: slot("accent1") ?? fallback.accent,
@@ -158,4 +157,4 @@ var Palette = class {
158
157
  };
159
158
  const colorMapOf = (master) => master === void 0 ? void 0 : kids(master, "p:clrMap")[0];
160
159
  //#endregion
161
- export { colorMapOf, Palette as default, themeOf };
160
+ export { colorMapOf, Palette as default, familyOf, themeOf };
@@ -1,6 +1,7 @@
1
1
  import { SaveImage } from "./images.js";
2
2
  import { DeckContent } from "@pollium/vyva-core/content";
3
3
  //#region src/import/pptx.d.ts
4
+ export declare const MAX_SLIDES = 300;
4
5
  export interface ImportedDeck {
5
6
  title: string;
6
7
  content: DeckContent;
@@ -1,7 +1,9 @@
1
1
  import { ConvertError } from "../errors.js";
2
2
  import { titleOf } from "./titles.js";
3
3
  import { imageSaver } from "./images.js";
4
- import { all, attr, first, num } from "./pptx-xml.js";
4
+ import { formatFor } from "./deck-format.js";
5
+ import { all, attr, first, num, textOf } from "./pptx-xml.js";
6
+ import { familyOf } from "./pptx-theme.js";
5
7
  import PptxPackage from "./pptx-package.js";
6
8
  import SlideReader from "./pptx-slide.js";
7
9
  import { DECK_SIZES } from "@pollium/vyva-core/content";
@@ -22,38 +24,21 @@ const IMAGE_TYPES = {
22
24
  gif: "image/gif",
23
25
  webp: "image/webp"
24
26
  };
25
- const FORMATS = [
26
- {
27
- format: "16:9",
28
- ratio: 16 / 9
29
- },
30
- {
31
- format: "4:3",
32
- ratio: 4 / 3
33
- },
34
- {
35
- format: "1:1",
36
- ratio: 1
37
- },
38
- {
39
- format: "a4-landscape",
40
- ratio: 1527 / 1080
41
- },
42
- {
43
- format: "a4-portrait",
44
- ratio: 1080 / 1527
45
- }
46
- ];
47
- const formatFor = (cx, cy) => {
48
- const ratio = cx / Math.max(cy, 1);
49
- return FORMATS.reduce((best, candidate) => Math.abs(candidate.ratio - ratio) < Math.abs(best.ratio - ratio) ? candidate : best).format;
50
- };
51
27
  const plainText = (html) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
52
28
  const deckTitle = (titles, slides, filename) => {
53
29
  const biggest = slides[0]?.elements.filter((element) => element.type === "text" && !element.isLocked).sort((a, b) => b.style.fontSize - a.style.fontSize)[0];
54
30
  const title = titles.find((candidate) => candidate !== "") ?? (biggest === void 0 ? "" : plainText(biggest.html));
55
31
  return titleOf(title, filename);
56
32
  };
33
+ const dominantFont = (parts, themeFont) => {
34
+ const counts = /* @__PURE__ */ new Map();
35
+ for (const { slide } of parts) for (const run of all(slide.root, "a:r")) {
36
+ const typeface = attr(first(run, "a:latin"), "typeface");
37
+ const family = typeface === void 0 || typeface.startsWith("+") ? themeFont : familyOf(typeface);
38
+ if (family !== null) counts.set(family, (counts.get(family) ?? 0) + all(run, "a:t").map(textOf).join("").length);
39
+ }
40
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? themeFont;
41
+ };
57
42
  const unzipped = (buffer) => {
58
43
  let total = 0;
59
44
  return unzipSync(buffer, { filter: (file) => {
@@ -72,9 +57,13 @@ const importPptx = async (buffer, filename, saveImage) => {
72
57
  const cy = num(attr(size, "cy"), DEFAULT_HEIGHT);
73
58
  const format = formatFor(cx, cy);
74
59
  const relations = pptx.relationsOf("ppt/presentation.xml");
75
- const allParts = all(presentation, "p:sldId").map((slide) => relations[attr(slide, "r:id") ?? ""]?.target).filter((path) => path !== void 0).slice(0, MAX_SLIDES).map((path) => pptx.parts(path)).filter((parts) => parts !== null);
76
- const theme = allParts[0]?.slide.palette.deckTheme();
77
- if (theme === void 0) throw new ConvertError("empty", `${filename} has no slides`);
60
+ const allParts = all(presentation, "p:sldId").map((slide) => relations[attr(slide, "r:id") ?? ""]?.target).filter((path) => path !== void 0).slice(0, 300).map((path) => pptx.parts(path)).filter((parts) => parts !== null);
61
+ const themed = allParts[0]?.slide.palette.deckTheme();
62
+ if (themed === void 0) throw new ConvertError("empty", `${filename} has no slides`);
63
+ const theme = {
64
+ ...themed,
65
+ font: dominantFont(allParts, themed.font)
66
+ };
78
67
  const save = imageSaver(saveImage, filename, MAX_IMAGES);
79
68
  const saved = /* @__PURE__ */ new Map();
80
69
  const reader = new SlideReader({
@@ -109,4 +98,4 @@ const importPptx = async (buffer, filename, saveImage) => {
109
98
  };
110
99
  };
111
100
  //#endregion
112
- export { importPptx };
101
+ export { MAX_SLIDES, importPptx };
package/dist/import.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { SaveImage } from "./import/images.js";
2
+ import { OfficeEngine } from "./import/office.js";
2
3
  import { Content } from "@pollium/vyva-core/content";
3
4
  //#region src/import.d.ts
4
5
  export declare const MAX_IMPORT_BYTES: number;
5
6
  export interface ImportOptions {
6
7
  saveImage: SaveImage;
7
8
  maxBytes?: number;
9
+ officeEngine?: OfficeEngine;
8
10
  }
9
11
  export interface ImportedContent {
10
12
  title: string;
11
13
  content: Content;
12
14
  }
13
- export declare const importFile: (data: Uint8Array, filename: string, { saveImage, maxBytes }: ImportOptions) => Promise<ImportedContent>;
15
+ export declare const importFile: (data: Uint8Array, filename: string, options: ImportOptions) => Promise<ImportedContent>;
14
16
  //#endregion
package/dist/import.js CHANGED
@@ -1,22 +1,44 @@
1
1
  import { ConvertError } from "./errors.js";
2
2
  import { importDocx, importMarkdown, importPdf, importPlainText } from "./import/documents.js";
3
+ import { imageTypeOf, importImage } from "./import/image.js";
4
+ import { runOffice } from "./import/office.js";
3
5
  import { importPptx } from "./import/pptx.js";
4
- import { unzipSync } from "fflate";
6
+ import { importPdfSlides } from "./import/pdf-slides.js";
7
+ import { strFromU8, unzipSync } from "fflate";
5
8
  //#region src/import.ts
6
9
  const MAX_IMPORT_BYTES = 26214400;
7
10
  const ZIP_SIGNATURE = 67324752;
8
11
  const PDF_SIGNATURE = "%PDF-";
12
+ const RTF_SIGNATURE = "{\\rtf";
13
+ const OLE_SIGNATURE = Buffer.from("d0cf11e0a1b11ae1", "hex");
9
14
  const TEXT_SAMPLE_BYTES = 65536;
15
+ const MAX_MIMETYPE_BYTES = 128;
10
16
  const BOM = /^/;
17
+ const OPEN_DOCUMENTS = {
18
+ "application/vnd.oasis.opendocument.text": "odt",
19
+ "application/vnd.oasis.opendocument.text-template": "odt",
20
+ "application/vnd.oasis.opendocument.presentation": "odp",
21
+ "application/vnd.oasis.opendocument.presentation-template": "odp"
22
+ };
23
+ const OLE_STREAMS = [{
24
+ stream: Buffer.from("WordDocument", "utf16le"),
25
+ format: "doc"
26
+ }, {
27
+ stream: Buffer.from("PowerPoint Document", "utf16le"),
28
+ format: "ppt"
29
+ }];
11
30
  const zipParts = (buffer) => {
12
31
  if (buffer.length < 4 || buffer.readUInt32LE(0) !== ZIP_SIGNATURE) return null;
13
32
  try {
14
33
  const names = /* @__PURE__ */ new Set();
15
- unzipSync(buffer, { filter: (file) => {
34
+ const read = unzipSync(buffer, { filter: (file) => {
16
35
  names.add(file.name);
17
- return false;
36
+ return file.name === "mimetype" && file.originalSize <= MAX_MIMETYPE_BYTES;
18
37
  } });
19
- return names;
38
+ return {
39
+ names,
40
+ mimetype: read.mimetype === void 0 ? "" : strFromU8(read.mimetype)
41
+ };
20
42
  } catch {
21
43
  return null;
22
44
  }
@@ -32,33 +54,62 @@ const isUtf8Text = (buffer) => {
32
54
  }
33
55
  };
34
56
  const detect = (buffer, filename) => {
35
- const parts = zipParts(buffer);
36
- if (parts !== null) {
37
- if (parts.has("word/document.xml")) return "docx";
38
- return parts.has("ppt/presentation.xml") ? "pptx" : null;
57
+ const zip = zipParts(buffer);
58
+ if (zip !== null) {
59
+ if (zip.names.has("word/document.xml")) return "docx";
60
+ if (zip.names.has("ppt/presentation.xml")) return "pptx";
61
+ return OPEN_DOCUMENTS[zip.mimetype] ?? null;
39
62
  }
40
63
  if (buffer.subarray(0, 5).toString("latin1") === PDF_SIGNATURE) return "pdf";
64
+ if (buffer.subarray(0, OLE_SIGNATURE.length).equals(OLE_SIGNATURE)) return OLE_STREAMS.find(({ stream }) => buffer.includes(stream))?.format ?? null;
65
+ if (buffer.subarray(0, 5).toString("latin1") === RTF_SIGNATURE) return "rtf";
66
+ const image = imageTypeOf(buffer);
67
+ if (image !== null) return image;
41
68
  if (!isUtf8Text(buffer)) return null;
42
69
  const extension = filename.toLowerCase().split(".").at(-1) ?? "";
43
70
  if (extension === "md" || extension === "markdown") return "markdown";
44
71
  if (extension === "txt") return "text";
45
72
  return null;
46
73
  };
47
- const convert = async (detected, buffer, filename, saveImage) => {
48
- if (detected === "docx") return importDocx(buffer, filename, saveImage);
49
- if (detected === "pptx") return importPptx(buffer, filename, saveImage);
50
- if (detected === "pdf") return importPdf(buffer, filename);
51
- const text = buffer.toString("utf8").replace(BOM, "");
52
- return detected === "markdown" ? importMarkdown(text, filename) : importPlainText(text, filename);
74
+ const textOf = (buffer) => buffer.toString("utf8").replace(BOM, "");
75
+ const upgraded = async (from, to, buffer, filename, officeEngine) => {
76
+ if (officeEngine === void 0) throw new ConvertError("unsupported", `${filename} is a ${from} file, which Vyva imports only through an office engine`);
77
+ const converted = await runOffice(officeEngine, {
78
+ file: buffer,
79
+ from,
80
+ to
81
+ }, filename);
82
+ if (detect(converted, filename) !== to) throw new ConvertError("office-failed", `The office engine did not turn ${filename} into ${to}`);
83
+ return converted;
84
+ };
85
+ const convert = async (detected, buffer, filename, options) => {
86
+ const { saveImage, officeEngine } = options;
87
+ switch (detected) {
88
+ case "docx": return importDocx(buffer, filename, saveImage);
89
+ case "pptx": return importPptx(buffer, filename, saveImage);
90
+ case "pdf": return officeEngine === void 0 ? importPdf(buffer, filename) : importPdfSlides(buffer, filename, saveImage, officeEngine);
91
+ case "markdown": return importMarkdown(textOf(buffer), filename);
92
+ case "text": return importPlainText(textOf(buffer), filename);
93
+ case "image/png":
94
+ case "image/jpeg":
95
+ case "image/gif":
96
+ case "image/webp": return importImage(buffer, filename, detected, saveImage);
97
+ case "doc":
98
+ case "odt":
99
+ case "rtf": return convert("docx", await upgraded(detected, "docx", buffer, filename, officeEngine), filename, options);
100
+ case "ppt":
101
+ case "odp": return convert("pptx", await upgraded(detected, "pptx", buffer, filename, officeEngine), filename, options);
102
+ }
53
103
  };
54
104
  const isEmpty = (content) => content.kind === "document" ? content.blocks.length === 0 : content.slides.length === 0;
55
- const importFile = async (data, filename, { saveImage, maxBytes = MAX_IMPORT_BYTES }) => {
105
+ const importFile = async (data, filename, options) => {
106
+ const { maxBytes = MAX_IMPORT_BYTES } = options;
56
107
  if (data.length > maxBytes) throw new ConvertError("too-large", `The file is larger than ${maxBytes} bytes`);
57
108
  const buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
58
109
  const name = filename.replace(/[\\/]/g, "_").slice(-200);
59
110
  const detected = detect(buffer, name);
60
111
  if (detected === null) throw new ConvertError("unsupported", `${name} is not a file Vyva can import`);
61
- const imported = await convert(detected, buffer, name, saveImage);
112
+ const imported = await convert(detected, buffer, name, options);
62
113
  if (isEmpty(imported.content)) throw new ConvertError("empty", `${name} has nothing to import`);
63
114
  return imported;
64
115
  };
@@ -0,0 +1,8 @@
1
+ import { OfficeEngine } from "./import/office.js";
2
+ //#region src/libreoffice.d.ts
3
+ export interface LibreOfficeOptions {
4
+ command?: string;
5
+ timeoutMs?: number;
6
+ }
7
+ export declare const libreOffice: ({ command, timeoutMs }?: LibreOfficeOptions) => OfficeEngine;
8
+ //#endregion
@@ -0,0 +1,39 @@
1
+ import { ConvertError } from "./errors.js";
2
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { execFile } from "node:child_process";
5
+ import { tmpdir } from "node:os";
6
+ import { pathToFileURL } from "node:url";
7
+ import { promisify } from "node:util";
8
+ //#region src/libreoffice.ts
9
+ const DEFAULT_TIMEOUT_MS = 12e4;
10
+ const run = promisify(execFile);
11
+ const libreOffice = ({ command = "soffice", timeoutMs = DEFAULT_TIMEOUT_MS } = {}) => async ({ file, from, to }) => {
12
+ const folder = await mkdtemp(join(tmpdir(), "vyva-office-"));
13
+ const source = join(folder, `source.${from}`);
14
+ try {
15
+ await writeFile(source, file);
16
+ await run(command, [
17
+ `-env:UserInstallation=${pathToFileURL(join(folder, "profile")).href}`,
18
+ "--headless",
19
+ ...from === "pdf" ? ["--infilter=impress_pdf_import"] : [],
20
+ "--convert-to",
21
+ to,
22
+ "--outdir",
23
+ folder,
24
+ source
25
+ ], { timeout: timeoutMs }).catch((error) => {
26
+ throw new ConvertError("office-failed", `LibreOffice at ${command} could not turn the ${from} file into ${to}`, { cause: error });
27
+ });
28
+ return new Uint8Array(await readFile(join(folder, `source.${to}`)).catch((error) => {
29
+ throw new ConvertError("office-failed", `LibreOffice finished without writing the ${to} file`, { cause: error });
30
+ }));
31
+ } finally {
32
+ await rm(folder, {
33
+ recursive: true,
34
+ force: true
35
+ });
36
+ }
37
+ };
38
+ //#endregion
39
+ export { libreOffice };
@@ -1,4 +1,5 @@
1
1
  import { FontFamily, TextStyle } from "@pollium/vyva-core/content";
2
2
  //#region src/text-measure.d.ts
3
3
  export declare const textHeight: (html: string, style: TextStyle, font: FontFamily, width: number) => number;
4
+ export declare const unwrappedWidth: (html: string, style: TextStyle, font: FontFamily) => number;
4
5
  //#endregion
@@ -94,5 +94,9 @@ const textHeight = (html, style, font, width) => {
94
94
  return height + linesOf(block.glyphs, style.fontSize, (width - indent) * WIDTH_SAFETY) * style.fontSize * style.lineHeight + gap;
95
95
  }, 0);
96
96
  };
97
+ const unwrappedWidth = (html, style, font) => {
98
+ const widths = blocksOf(html, font, style.isBold).map((block) => block.glyphs.reduce((width, glyph) => width + widthOf(glyph, style.fontSize), block.isItem ? LIST_INDENT_EMS * style.fontSize : 0));
99
+ return Math.ceil(Math.max(0, ...widths) / WIDTH_SAFETY) + 1;
100
+ };
97
101
  //#endregion
98
- export { textHeight };
102
+ export { textHeight, unwrappedWidth };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pollium/vyva-convert",
3
- "version": "0.1.0",
4
- "description": "Server-side tools for Vyva content: clean what clients send, import Word, PowerPoint, PDF and Markdown files, and export to PDF, Word, PowerPoint and HTML.",
3
+ "version": "0.3.0",
4
+ "description": "Server-side tools for Vyva content: clean what clients send, import Word, PowerPoint, PDF, OpenDocument, Markdown and image files, and export to PDF, Word, PowerPoint and HTML.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
7
7
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "pptxgenjs": "^4.0.1",
38
38
  "sanitize-html": "^2.18.0",
39
39
  "unpdf": "^1.8.1",
40
- "@pollium/vyva-core": "0.1.0"
40
+ "@pollium/vyva-core": "0.2.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.6.4",
@@ -47,6 +47,7 @@
47
47
  "build": "tsdown",
48
48
  "typecheck": "tsc -p tsconfig.json",
49
49
  "lint": "oxlint --deny-warnings",
50
- "test": "vitest run"
50
+ "test": "vitest run",
51
+ "font-widths": "node scripts/font-widths.ts"
51
52
  }
52
53
  }