@pollium/vyva-convert 0.1.0 → 0.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.
package/README.md CHANGED
@@ -70,8 +70,19 @@ const { title, content } = await importFile(upload.bytes, upload.filename, {
70
70
  by what their zip holds, and PDF by its signature. Only Markdown (`.md`) and text (`.txt`) are
71
71
  recognized by their extension, and only when they are valid UTF-8.
72
72
  - 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.
73
+ keeps its theme colors and the font most of its text uses. What its slide master draws comes in
74
+ locked, behind the slide's own elements, and freeform shapes come in as polygons.
75
+ - With `pdfSlides`, a PDF whose pages are landscape becomes a deck instead: each page a slide, with
76
+ its pictures and shapes where they were, and the loose lines a PDF keeps joined back into
77
+ paragraphs and lists. A portrait PDF stays a document. `pdfSlides` takes the PDF and returns it as
78
+ a PowerPoint file; `libreOffice()` from `@pollium/vyva-convert/libreoffice` does that with the
79
+ `soffice` on the machine (`command` changes the path), one conversion per call:
80
+
81
+ ```ts
82
+ import { libreOffice } from '@pollium/vyva-convert/libreoffice';
83
+
84
+ await importFile(upload.bytes, upload.filename, { saveImage, pdfSlides: libreOffice() });
85
+ ```
75
86
  - `saveImage` stores each image and returns its file id, which must be a UUID. Return `null` to
76
87
  leave the image out. If it throws, the import fails with your error.
77
88
  - Files over 25 MB (`maxBytes` changes the limit) and files it cannot read are refused.
@@ -87,3 +98,4 @@ a bug or a broken file, so log it.
87
98
  | `too-large` | The file, or what it unzips to, is over the limit |
88
99
  | `empty` | The file has nothing to import |
89
100
  | `pdf-failed` | The PDF engine could not be reached or answered with an error |
101
+ | `slides-failed` | `pdfSlides` could not turn a PDF into slides |
@@ -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' | 'slides-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 { 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) 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,7 @@
1
+ import { SaveImage } from "./images.js";
2
+ import { ImportedDeck } from "./pptx.js";
3
+ //#region src/import/pdf-slides.d.ts
4
+ export type PdfSlides = (pdf: Uint8Array) => Promise<Uint8Array>;
5
+ export declare const isLandscapePdf: (buffer: Buffer) => Promise<boolean>;
6
+ export declare const importPdfSlides: (buffer: Buffer, filename: string, saveImage: SaveImage, pdfSlides: PdfSlides) => Promise<ImportedDeck>;
7
+ //#endregion
@@ -0,0 +1,21 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { joinLines } from "./pdf-lines.js";
3
+ import { importPptx } from "./pptx.js";
4
+ import { getDocumentProxy } from "unpdf";
5
+ //#region src/import/pdf-slides.ts
6
+ const isLandscapePdf = async (buffer) => {
7
+ const { width, height } = (await (await getDocumentProxy(new Uint8Array(buffer))).getPage(1)).getViewport({ scale: 1 });
8
+ return width > height;
9
+ };
10
+ const importPdfSlides = async (buffer, filename, saveImage, pdfSlides) => {
11
+ const pptx = await pdfSlides(new Uint8Array(buffer)).catch((error) => {
12
+ throw error instanceof ConvertError ? error : new ConvertError("slides-failed", `${filename} could not be turned into slides`, { cause: error });
13
+ });
14
+ const { title, content } = await importPptx(Buffer.from(pptx), filename, saveImage);
15
+ return {
16
+ title,
17
+ content: joinLines(content)
18
+ };
19
+ };
20
+ //#endregion
21
+ export { importPdfSlides, isLandscapePdf };
@@ -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,7 +1,8 @@
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 { all, attr, first, num, textOf } from "./pptx-xml.js";
5
+ import { familyOf } from "./pptx-theme.js";
5
6
  import PptxPackage from "./pptx-package.js";
6
7
  import SlideReader from "./pptx-slide.js";
7
8
  import { DECK_SIZES } from "@pollium/vyva-core/content";
@@ -54,6 +55,15 @@ const deckTitle = (titles, slides, filename) => {
54
55
  const title = titles.find((candidate) => candidate !== "") ?? (biggest === void 0 ? "" : plainText(biggest.html));
55
56
  return titleOf(title, filename);
56
57
  };
58
+ const dominantFont = (parts, themeFont) => {
59
+ const counts = /* @__PURE__ */ new Map();
60
+ for (const { slide } of parts) for (const run of all(slide.root, "a:r")) {
61
+ const typeface = attr(first(run, "a:latin"), "typeface");
62
+ const family = typeface === void 0 || typeface.startsWith("+") ? themeFont : familyOf(typeface);
63
+ if (family !== null) counts.set(family, (counts.get(family) ?? 0) + all(run, "a:t").map(textOf).join("").length);
64
+ }
65
+ return [...counts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? themeFont;
66
+ };
57
67
  const unzipped = (buffer) => {
58
68
  let total = 0;
59
69
  return unzipSync(buffer, { filter: (file) => {
@@ -73,8 +83,12 @@ const importPptx = async (buffer, filename, saveImage) => {
73
83
  const format = formatFor(cx, cy);
74
84
  const relations = pptx.relationsOf("ppt/presentation.xml");
75
85
  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`);
86
+ const themed = allParts[0]?.slide.palette.deckTheme();
87
+ if (themed === void 0) throw new ConvertError("empty", `${filename} has no slides`);
88
+ const theme = {
89
+ ...themed,
90
+ font: dominantFont(allParts, themed.font)
91
+ };
78
92
  const save = imageSaver(saveImage, filename, MAX_IMAGES);
79
93
  const saved = /* @__PURE__ */ new Map();
80
94
  const reader = new SlideReader({
package/dist/import.d.ts CHANGED
@@ -1,14 +1,16 @@
1
1
  import { SaveImage } from "./import/images.js";
2
+ import { PdfSlides } from "./import/pdf-slides.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
+ pdfSlides?: PdfSlides;
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,6 +1,7 @@
1
1
  import { ConvertError } from "./errors.js";
2
2
  import { importDocx, importMarkdown, importPdf, importPlainText } from "./import/documents.js";
3
3
  import { importPptx } from "./import/pptx.js";
4
+ import { importPdfSlides, isLandscapePdf } from "./import/pdf-slides.js";
4
5
  import { unzipSync } from "fflate";
5
6
  //#region src/import.ts
6
7
  const MAX_IMPORT_BYTES = 26214400;
@@ -44,21 +45,22 @@ const detect = (buffer, filename) => {
44
45
  if (extension === "txt") return "text";
45
46
  return null;
46
47
  };
47
- const convert = async (detected, buffer, filename, saveImage) => {
48
+ const convert = async (detected, buffer, filename, { saveImage, pdfSlides }) => {
48
49
  if (detected === "docx") return importDocx(buffer, filename, saveImage);
49
50
  if (detected === "pptx") return importPptx(buffer, filename, saveImage);
50
- if (detected === "pdf") return importPdf(buffer, filename);
51
+ if (detected === "pdf") return pdfSlides !== void 0 && await isLandscapePdf(buffer) ? importPdfSlides(buffer, filename, saveImage, pdfSlides) : importPdf(buffer, filename);
51
52
  const text = buffer.toString("utf8").replace(BOM, "");
52
53
  return detected === "markdown" ? importMarkdown(text, filename) : importPlainText(text, filename);
53
54
  };
54
55
  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 }) => {
56
+ const importFile = async (data, filename, options) => {
57
+ const { maxBytes = MAX_IMPORT_BYTES } = options;
56
58
  if (data.length > maxBytes) throw new ConvertError("too-large", `The file is larger than ${maxBytes} bytes`);
57
59
  const buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
58
60
  const name = filename.replace(/[\\/]/g, "_").slice(-200);
59
61
  const detected = detect(buffer, name);
60
62
  if (detected === null) throw new ConvertError("unsupported", `${name} is not a file Vyva can import`);
61
- const imported = await convert(detected, buffer, name, saveImage);
63
+ const imported = await convert(detected, buffer, name, options);
62
64
  if (isEmpty(imported.content)) throw new ConvertError("empty", `${name} has nothing to import`);
63
65
  return imported;
64
66
  };
@@ -0,0 +1,8 @@
1
+ import { PdfSlides } from "./import/pdf-slides.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) => PdfSlides;
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 (pdf) => {
12
+ const folder = await mkdtemp(join(tmpdir(), "vyva-slides-"));
13
+ const source = join(folder, "source.pdf");
14
+ try {
15
+ await writeFile(source, pdf);
16
+ await run(command, [
17
+ `-env:UserInstallation=${pathToFileURL(join(folder, "profile")).href}`,
18
+ "--headless",
19
+ "--infilter=impress_pdf_import",
20
+ "--convert-to",
21
+ "pptx",
22
+ "--outdir",
23
+ folder,
24
+ source
25
+ ], { timeout: timeoutMs }).catch((error) => {
26
+ throw new ConvertError("slides-failed", `LibreOffice at ${command} could not turn the PDF into slides`, { cause: error });
27
+ });
28
+ return new Uint8Array(await readFile(join(folder, "source.pptx")).catch((error) => {
29
+ throw new ConvertError("slides-failed", "LibreOffice finished without writing the slides", { 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pollium/vyva-convert",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -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
  }