@pollium/vyva-convert 0.2.0 → 0.4.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
@@ -46,6 +47,9 @@ reply.header('content-disposition', contentDisposition('Subnetting guide', file.
46
47
  |---|---|
47
48
  | Document | `pdf`, `docx`, `html` |
48
49
  | Deck | `pptx`, `pdf` |
50
+ | Deck of PDF pages | `pdf` |
51
+
52
+ `exportFormatsOf(content)` lists them, and `canExport(content, format)` checks one.
49
53
 
50
54
  - `readImage` is called once for every image the content uses. Return `null` and the export goes
51
55
  on without that image.
@@ -55,6 +59,9 @@ reply.header('content-disposition', contentDisposition('Subnetting guide', file.
55
59
  page size in inches) and returns the PDF. Use `gotenberg` or write your own with Puppeteer or
56
60
  Playwright.
57
61
  - The `html` export is one self-contained file, with its images and fonts inline.
62
+ - A deck of PDF pages exports as the original PDF, read through `readImage`, with what the slides
63
+ add printed on top of each page. Pages with nothing added are copied as they are, and a deck
64
+ nobody drew on never reaches the PDF engine. An encrypted PDF is refused as `unsupported`.
58
65
 
59
66
  ## Importing
60
67
 
@@ -66,22 +73,33 @@ const { title, content } = await importFile(upload.bytes, upload.filename, {
66
73
  });
67
74
  ```
68
75
 
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.
72
- - Word, PDF, Markdown and text files become documents, and PowerPoint files become decks. A deck
76
+ - The file is recognized by its content, not its name. Word, PowerPoint and OpenDocument files
77
+ are told apart by what their zip holds, Word 97 and PowerPoint 97 files by the streams they hold,
78
+ and PDF, RTF, PNG, JPEG, GIF and WebP by their signatures. Only Markdown (`.md`) and text
79
+ (`.txt`) are recognized by their extension, and only when they are valid UTF-8.
80
+ - Word, Markdown and text files become documents, and PowerPoint files become decks. A deck
73
81
  keeps its theme colors and the font most of its text uses. What its slide master draws comes in
74
82
  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:
83
+ - A PDF becomes a deck of its own pages: one slide for each page, at the size of the first page,
84
+ with that page behind it. Nothing is converted, so it looks exactly like the original.
85
+ `saveImage` stores the PDF once, as `application/pdf`; return `null` and the import is refused as
86
+ `empty`. A PDF Vyva cannot open is refused as `unsupported`, and one of more than 1000 pages as
87
+ `too-large`.
88
+ - A picture becomes a deck of one slide, in the format closest to its shape, with the picture
89
+ filling it. `saveImage` stores it like any other image; return `null` and the import is refused
90
+ as `empty`.
91
+ - With `officeEngine`, Vyva reads what it cannot read alone. Word 97, OpenDocument text and RTF
92
+ files are turned into Word, and PowerPoint 97 and OpenDocument presentations into PowerPoint,
93
+ then imported as those. Without an engine those formats are refused as `unsupported`.
94
+
95
+ `officeEngine` takes `{ file, from, to }` and returns the converted file. `libreOffice()` from
96
+ `@pollium/vyva-convert/libreoffice` does that with the `soffice` on the machine (`command` changes
97
+ the path), one conversion per call:
80
98
 
81
99
  ```ts
82
100
  import { libreOffice } from '@pollium/vyva-convert/libreoffice';
83
101
 
84
- await importFile(upload.bytes, upload.filename, { saveImage, pdfSlides: libreOffice() });
102
+ await importFile(upload.bytes, upload.filename, { saveImage, officeEngine: libreOffice() });
85
103
  ```
86
104
  - `saveImage` stores each image and returns its file id, which must be a UUID. Return `null` to
87
105
  leave the image out. If it throws, the import fails with your error.
@@ -98,4 +116,4 @@ a bug or a broken file, so log it.
98
116
  | `too-large` | The file, or what it unzips to, is over the limit |
99
117
  | `empty` | The file has nothing to import |
100
118
  | `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 |
119
+ | `office-failed` | `officeEngine` could not convert the file, or answered with something else |
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' | 'slides-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);
@@ -18,6 +18,7 @@ export interface ExportBundle {
18
18
  title: string;
19
19
  language: string;
20
20
  images: Map<string, BundleImage>;
21
+ pdfs: Map<string, Buffer>;
21
22
  fonts: FontFamily[];
22
23
  }
23
24
  export interface ExportedFile {
@@ -8,6 +8,7 @@ const EXTENSIONS = {
8
8
  "image/gif": "gif",
9
9
  "image/webp": "webp"
10
10
  };
11
+ const PDF_TYPE = "application/pdf";
11
12
  const fontsOf = (content) => {
12
13
  if (content.kind === "document") return [content.design.font, CODE_FONT];
13
14
  return content.slides.some((slide) => slide.elements.some((element) => element.type === "code")) ? [content.theme.font, CODE_FONT] : [content.theme.font];
@@ -27,13 +28,18 @@ const bundleImage = (id, stored) => {
27
28
  const bundleOf = async (content, { title, language, readImage }) => {
28
29
  const ids = fileIdsOf(content);
29
30
  const stored = await Promise.all(ids.map((id) => readImage(id)));
31
+ const found = ids.flatMap((id, index) => {
32
+ const file = stored[index];
33
+ return file === null ? [] : [{
34
+ id,
35
+ file
36
+ }];
37
+ });
30
38
  return {
31
39
  title,
32
40
  language,
33
- images: new Map(ids.flatMap((id, index) => {
34
- const image = stored[index];
35
- return image === null ? [] : [[id, bundleImage(id, image)]];
36
- })),
41
+ images: new Map(found.filter(({ file }) => file.contentType !== PDF_TYPE).map(({ id, file }) => [id, bundleImage(id, file)])),
42
+ pdfs: new Map(found.filter(({ file }) => file.contentType === PDF_TYPE).map(({ id, file }) => [id, Buffer.from(file.data)])),
37
43
  fonts: fontsOf(content)
38
44
  };
39
45
  };
@@ -1,5 +1,5 @@
1
1
  import { fontFaces, fontFilesOf } from "./fonts.js";
2
- import { DECK_SIZES } from "@pollium/vyva-core/content";
2
+ import { deckSize } from "@pollium/vyva-core/content";
3
3
  import { CONTENT_CSS, renderDocument, renderSlide } from "@pollium/vyva-core/render";
4
4
  import { escapeHtml } from "@pollium/vyva-core/escape";
5
5
  //#region src/export/html.ts
@@ -9,7 +9,7 @@ const SCREEN_CSS = "body{margin:0;background:#f3f4f6;display:flex;justify-conten
9
9
  const dataUrl = (contentType, data) => `data:${contentType};base64,${data.toString("base64")}`;
10
10
  const bodyOf = (content, fileUrl) => {
11
11
  if (content.kind === "document") return renderDocument(content, fileUrl);
12
- const { width, height } = DECK_SIZES[content.format];
12
+ const { width, height } = deckSize(content.format);
13
13
  const zoom = SCREEN_PX_PER_INCH / SLIDE_PX_PER_INCH;
14
14
  return content.slides.map((slide) => `<div class="vy-print-slide" style="width:${width * zoom}px;height:${height * zoom}px"><div style="width:${width}px;height:${height}px;transform:scale(${zoom});transform-origin:0 0">${renderSlide(slide, content, fileUrl)}</div></div>`).join("");
15
15
  };
@@ -0,0 +1,5 @@
1
+ import { ExportBundle } from "./bundle.js";
2
+ import { DeckContent } from "@pollium/vyva-core/content";
3
+ //#region src/export/pdf-pages.d.ts
4
+ export declare const onPdfPages: (deck: DeckContent, bundle: ExportBundle, print: () => Promise<Uint8Array>) => Promise<Uint8Array>;
5
+ //#endregion
@@ -0,0 +1,110 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { frameLayers } from "@pollium/vyva-core/deck-frame";
3
+ import { PDFDocument, concatTransformationMatrix, drawObject, popGraphicsState, pushGraphicsState } from "pdf-lib";
4
+ //#region src/export/pdf-pages.ts
5
+ const pdfPageOf = (slide) => slide.background?.type === "pdf-page" ? slide.background : null;
6
+ const drawsOver = (slide, deck) => {
7
+ const { under, over } = frameLayers(slide, deck);
8
+ return slide.elements.length > 0 || under !== "" || over !== "";
9
+ };
10
+ const loaded = (data, what) => PDFDocument.load(data).catch((error) => {
11
+ throw new ConvertError("unsupported", `${what} cannot be copied into the exported PDF`, { cause: error });
12
+ });
13
+ const placement = (page, overlay) => {
14
+ const { x, y, width, height } = page.getCropBox();
15
+ const rotation = (page.getRotation().angle % 360 + 360) % 360;
16
+ const isTurned = rotation === 90 || rotation === 270;
17
+ const visibleWidth = isTurned ? height : width;
18
+ const visibleHeight = isTurned ? width : height;
19
+ const scale = Math.min(visibleWidth / overlay.width, visibleHeight / overlay.height);
20
+ const left = (visibleWidth - overlay.width * scale) / 2;
21
+ const top = (visibleHeight - overlay.height * scale) / 2;
22
+ const tall = overlay.height * scale;
23
+ if (rotation === 90) return [
24
+ 0,
25
+ scale,
26
+ -scale,
27
+ 0,
28
+ x + top + tall,
29
+ y + left
30
+ ];
31
+ if (rotation === 180) return [
32
+ -scale,
33
+ 0,
34
+ 0,
35
+ -scale,
36
+ x + width - left,
37
+ y + top + tall
38
+ ];
39
+ if (rotation === 270) return [
40
+ 0,
41
+ -scale,
42
+ scale,
43
+ 0,
44
+ x + width - top - tall,
45
+ y + height - left
46
+ ];
47
+ return [
48
+ scale,
49
+ 0,
50
+ 0,
51
+ scale,
52
+ x + left,
53
+ y + height - top - tall
54
+ ];
55
+ };
56
+ const stamp = (page, overlay) => {
57
+ const { context } = page.doc;
58
+ page.node.normalize();
59
+ page.node.wrapContentStreams(context.register(context.contentStream([pushGraphicsState()])), context.register(context.contentStream([popGraphicsState()])));
60
+ const name = page.node.newXObject("VyvaOverlay", overlay.ref);
61
+ page.pushOperators(pushGraphicsState(), concatTransformationMatrix(...placement(page, overlay)), drawObject(name), popGraphicsState());
62
+ };
63
+ const sourcePagesOf = (deck, pdfs) => deck.slides.flatMap((slide, index) => {
64
+ const background = pdfPageOf(slide);
65
+ return background !== null && pdfs.has(background.fileId) ? [{
66
+ slide: index,
67
+ fileId: background.fileId,
68
+ page: background.page
69
+ }] : [];
70
+ });
71
+ const copySources = async (output, wanted, pdfs) => {
72
+ const placed = [];
73
+ for (const [fileId, data] of pdfs) {
74
+ const pages = wanted.filter((source) => source.fileId === fileId);
75
+ if (pages.length === 0) continue;
76
+ const source = await loaded(data, `The PDF ${fileId}`);
77
+ if (pages.some(({ page }) => page > source.getPageCount())) throw new ConvertError("unsupported", `The PDF ${fileId} has fewer pages than the deck shows`);
78
+ const copied = await output.copyPages(source, pages.map(({ page }) => page - 1));
79
+ placed.push(...copied.map((page, at) => ({
80
+ slide: pages[at].slide,
81
+ page
82
+ })));
83
+ }
84
+ return placed;
85
+ };
86
+ const copyPrinted = async (output, printed, slides) => {
87
+ return (await output.copyPages(printed, slides)).map((page, at) => ({
88
+ slide: slides[at],
89
+ page
90
+ }));
91
+ };
92
+ const stampAll = async (output, printed, sources, deck) => {
93
+ const stamped = sources.filter(({ slide }) => drawsOver(deck.slides[slide], deck));
94
+ const overlays = await output.embedPages(stamped.map(({ slide }) => printed.getPage(slide)));
95
+ stamped.forEach(({ page }, index) => stamp(page, overlays[index]));
96
+ };
97
+ const onPdfPages = async (deck, bundle, print) => {
98
+ const wanted = sourcePagesOf(deck, bundle.pdfs);
99
+ const fromSource = new Set(wanted.map(({ slide }) => slide));
100
+ const printed = deck.slides.some((slide, index) => !fromSource.has(index) || drawsOver(slide, deck)) ? await loaded(await print(), "The printed slides") : null;
101
+ const output = await PDFDocument.create();
102
+ output.setTitle(bundle.title);
103
+ const sources = await copySources(output, wanted, bundle.pdfs);
104
+ const others = printed === null ? [] : await copyPrinted(output, printed, deck.slides.map((_, index) => index).filter((index) => !fromSource.has(index)));
105
+ if (printed !== null) await stampAll(output, printed, sources, deck);
106
+ for (const { page } of [...sources, ...others].sort((a, b) => a.slide - b.slide)) output.addPage(page);
107
+ return output.save();
108
+ };
109
+ //#endregion
110
+ export { onPdfPages };
@@ -1,6 +1,7 @@
1
1
  import { fontFaces, fontFilesOf } from "./fonts.js";
2
2
  import { bodyOf, pageHtml } from "./html.js";
3
- import { DECK_SIZES } from "@pollium/vyva-core/content";
3
+ import { onPdfPages } from "./pdf-pages.js";
4
+ import { deckSize, hasPdfPages } from "@pollium/vyva-core/content";
4
5
  import { CONTENT_CSS } from "@pollium/vyva-core/render";
5
6
  import { escapeHtml } from "@pollium/vyva-core/escape";
6
7
  //#region src/export/pdf.ts
@@ -31,7 +32,7 @@ html,body{margin:0;padding:0;-webkit-print-color-adjust:exact;print-color-adjust
31
32
  const inches = (mm) => mm / MM_PER_INCH;
32
33
  const pageOf = (content) => {
33
34
  if (content.kind === "deck") {
34
- const { width, height } = DECK_SIZES[content.format];
35
+ const { width, height } = deckSize(content.format);
35
36
  return {
36
37
  width: width / SLIDE_PX_PER_INCH,
37
38
  height: height / SLIDE_PX_PER_INCH,
@@ -58,24 +59,27 @@ const footerOf = (content) => {
58
59
  const side = (mm) => `${inches(mm).toFixed(3)}in`;
59
60
  return `<html><head><style>.vy-page-footer{box-sizing:border-box;display:flex;justify-content:space-between;width:100%;padding:0 ${side(design.margins.right)} 0 ${side(design.margins.left)};font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:8pt;color:#6b7280}</style></head><body><div class="vy-page-footer"><span>${text}</span>${number}</div></body></html>`;
60
61
  };
61
- const buildPdf = async (content, bundle, engine) => {
62
+ const print = async (content, bundle, engine) => {
62
63
  const fonts = await fontFilesOf(bundle.fonts);
63
64
  const css = `${fontFaces(fonts, (file) => file.name)}${CONTENT_CSS}${PRINT_CSS}`;
65
+ return engine({
66
+ html: pageHtml(bundle, css, bodyOf(content, (id) => bundle.images.get(id)?.name ?? null)),
67
+ files: [...[...bundle.images.values()].map(({ name, data, contentType }) => ({
68
+ name,
69
+ data,
70
+ contentType
71
+ })), ...fonts.map(({ name, data }) => ({
72
+ name,
73
+ data,
74
+ contentType: "font/woff2"
75
+ }))],
76
+ footerHtml: footerOf(content),
77
+ page: pageOf(content)
78
+ });
79
+ };
80
+ const buildPdf = async (content, bundle, engine) => {
64
81
  return {
65
- data: await engine({
66
- html: pageHtml(bundle, css, bodyOf(content, (id) => bundle.images.get(id)?.name ?? null)),
67
- files: [...[...bundle.images.values()].map(({ name, data, contentType }) => ({
68
- name,
69
- data,
70
- contentType
71
- })), ...fonts.map(({ name, data }) => ({
72
- name,
73
- data,
74
- contentType: "font/woff2"
75
- }))],
76
- footerHtml: footerOf(content),
77
- page: pageOf(content)
78
- }),
82
+ data: content.kind === "deck" && hasPdfPages(content) ? await onPdfPages(content, bundle, () => print(content, bundle, engine)) : await print(content, bundle, engine),
79
83
  contentType: "application/pdf",
80
84
  extension: "pdf"
81
85
  };
@@ -1,6 +1,6 @@
1
1
  import { blocksOfHtml } from "./html-blocks.js";
2
2
  import { CODE_FONT, CODE_LINE_HEIGHT, CODE_PALETTES, codeLines } from "@pollium/vyva-core/code";
3
- import { DECK_SIZES, TEXT_SHAPES } from "@pollium/vyva-core/content";
3
+ import { TEXT_SHAPES, deckSize } from "@pollium/vyva-core/content";
4
4
  import { bandHeight, footerHeight, footerSegments, hasBand, hasFooter, inkOn } from "@pollium/vyva-core/deck-frame";
5
5
  import PptxGenJS from "pptxgenjs";
6
6
  //#region src/export/pptx.ts
@@ -250,7 +250,7 @@ const addBackground = (slide, background, deck, bundle) => {
250
250
  if (image !== void 0) {
251
251
  slide.background = { data: dataUrl(image.contentType, image.data) };
252
252
  if (background.dim > 0) {
253
- const { width, height } = DECK_SIZES[deck.format];
253
+ const { width, height } = deckSize(deck.format);
254
254
  slide.addShape("rect", {
255
255
  x: 0,
256
256
  y: 0,
@@ -270,7 +270,7 @@ const addBackground = (slide, background, deck, bundle) => {
270
270
  };
271
271
  const addBand = (slide, source, deck) => {
272
272
  if (!hasBand(deck.theme, source)) return;
273
- const { width } = DECK_SIZES[deck.format];
273
+ const { width } = deckSize(deck.format);
274
274
  slide.addShape("rect", {
275
275
  x: 0,
276
276
  y: 0,
@@ -282,7 +282,7 @@ const addBand = (slide, source, deck) => {
282
282
  };
283
283
  const addFooter = (slide, source, deck) => {
284
284
  if (!hasFooter(deck.theme, source)) return;
285
- const { width, height } = DECK_SIZES[deck.format];
285
+ const { width, height } = deckSize(deck.format);
286
286
  const tall = footerHeight(deck.format);
287
287
  const third = width / 3;
288
288
  footerSegments(source, deck).forEach((segment, index) => {
@@ -302,7 +302,7 @@ const addFooter = (slide, source, deck) => {
302
302
  });
303
303
  };
304
304
  const buildPptx = async (deck, bundle) => {
305
- const { width, height } = DECK_SIZES[deck.format];
305
+ const { width, height } = deckSize(deck.format);
306
306
  const pptx = new PptxGenJS();
307
307
  pptx.defineLayout({
308
308
  name: LAYOUT,
package/dist/export.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface ExportOptions {
14
14
  language?: string;
15
15
  }
16
16
  export declare const isExportFormat: (value: unknown) => value is ExportFormat;
17
- export declare const canExport: (kind: ContentKind, format: ExportFormat) => boolean;
17
+ export declare const exportFormatsOf: (content: Content) => readonly ExportFormat[];
18
+ export declare const canExport: (content: Content, format: ExportFormat) => boolean;
18
19
  export declare const exportContent: (content: Content, format: ExportFormat, { title, readImage, pdfEngine, language }: ExportOptions) => Promise<ExportedFile>;
19
20
  //#endregion
package/dist/export.js CHANGED
@@ -4,6 +4,7 @@ import { buildDocx } from "./export/docx.js";
4
4
  import { buildHtml } from "./export/html.js";
5
5
  import { buildPdf } from "./export/pdf.js";
6
6
  import { buildPptx } from "./export/pptx.js";
7
+ import { hasPdfPages } from "@pollium/vyva-core/content";
7
8
  //#region src/export.ts
8
9
  const EXPORT_FORMATS = {
9
10
  document: [
@@ -16,15 +17,14 @@ const EXPORT_FORMATS = {
16
17
  const DEFAULT_LANGUAGE = "en";
17
18
  const FORMATS = new Set(Object.values(EXPORT_FORMATS).flat());
18
19
  const isExportFormat = (value) => typeof value === "string" && FORMATS.has(value);
19
- const canExport = (kind, format) => {
20
- return EXPORT_FORMATS[kind].includes(format);
21
- };
20
+ const exportFormatsOf = (content) => hasPdfPages(content) ? ["pdf"] : EXPORT_FORMATS[content.kind];
21
+ const canExport = (content, format) => exportFormatsOf(content).includes(format);
22
22
  const engineOf = (engine) => {
23
23
  if (engine === void 0) throw new TypeError("Exporting as pdf needs a pdfEngine");
24
24
  return engine;
25
25
  };
26
26
  const exportContent = async (content, format, { title, readImage, pdfEngine, language = DEFAULT_LANGUAGE }) => {
27
- if (!canExport(content.kind, format)) throw new ConvertError("unsupported", `A ${content.kind} cannot be exported as ${format}`);
27
+ if (!canExport(content, format)) throw new ConvertError("unsupported", `This ${content.kind} cannot be exported as ${format}`);
28
28
  const engine = format === "pdf" ? engineOf(pdfEngine) : null;
29
29
  const bundle = await bundleOf(content, {
30
30
  title,
@@ -36,4 +36,4 @@ const exportContent = async (content, format, { title, readImage, pdfEngine, lan
36
36
  return content.kind === "document" ? buildDocx(content, bundle) : buildPptx(content, bundle);
37
37
  };
38
38
  //#endregion
39
- export { EXPORT_FORMATS, canExport, exportContent, isExportFormat };
39
+ export { EXPORT_FORMATS, canExport, exportContent, exportFormatsOf, isExportFormat };
@@ -0,0 +1,4 @@
1
+ import { DeckPreset } from "@pollium/vyva-core/content";
2
+ //#region src/import/deck-format.d.ts
3
+ export declare const formatFor: (width: number, height: number) => DeckPreset;
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 };
@@ -8,5 +8,4 @@ export interface ImportedDocument {
8
8
  export declare const importDocx: (buffer: Buffer, filename: string, saveImage: SaveImage) => Promise<ImportedDocument>;
9
9
  export declare const importMarkdown: (text: string, filename: string) => ImportedDocument;
10
10
  export declare const importPlainText: (text: string, filename: string) => ImportedDocument;
11
- export declare const importPdf: (buffer: Buffer, filename: string) => Promise<ImportedDocument>;
12
11
  //#endregion
@@ -3,10 +3,8 @@ import { imageSaver } from "./images.js";
3
3
  import { markdownToBlocks } from "@pollium/vyva-core/markdown-blocks";
4
4
  import { DEFAULT_DOCUMENT_DESIGN } from "@pollium/vyva-core/themes";
5
5
  import mammoth from "mammoth";
6
- import { extractText, getDocumentProxy } from "unpdf";
7
6
  //#region src/import/documents.ts
8
7
  const MAX_IMAGES = 60;
9
- const PAGE_BREAK = "<div data-type=\"page-break\"></div>";
10
8
  const documentOf = (blocks, filename) => ({
11
9
  title: titleFrom(blocks, filename),
12
10
  content: {
@@ -35,10 +33,5 @@ const importDocx = async (buffer, filename, saveImage) => {
35
33
  };
36
34
  const importMarkdown = (text, filename) => documentOf(markdownToBlocks(text), filename);
37
35
  const importPlainText = (text, filename) => documentOf(htmlToBlocks(paragraphsHtml(text)), filename);
38
- const importPdf = async (buffer, filename) => {
39
- const { text } = await extractText(await getDocumentProxy(new Uint8Array(buffer)), { mergePages: false });
40
- const html = text.map((page) => paragraphsHtml(page)).filter((page) => page !== "").join(PAGE_BREAK);
41
- return documentOf(htmlToBlocks(html), filename);
42
- };
43
36
  //#endregion
44
- export { importDocx, importMarkdown, importPdf, importPlainText };
37
+ export { importDocx, importMarkdown, importPlainText };
@@ -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,58 @@
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 { PLAIN_THEME } from "./plain-theme.js";
7
+ import { DECK_SIZES } from "@pollium/vyva-core/content";
8
+ import { newId } from "@pollium/vyva-core/ids";
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
+ return {
29
+ title: titleOf("", filename),
30
+ content: {
31
+ kind: "deck",
32
+ format,
33
+ theme: PLAIN_THEME,
34
+ slides: [{
35
+ id: newId(),
36
+ background: null,
37
+ elements: [{
38
+ id: newId(),
39
+ type: "image",
40
+ x: Math.round((slide.width - w) / 2),
41
+ y: Math.round((slide.height - h) / 2),
42
+ w,
43
+ h,
44
+ rotation: 0,
45
+ opacity: 1,
46
+ isLocked: false,
47
+ fileId,
48
+ fit: "contain",
49
+ radius: 0,
50
+ alt: "",
51
+ credit: null
52
+ }]
53
+ }]
54
+ }
55
+ };
56
+ };
57
+ //#endregion
58
+ 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: 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,5 @@
1
+ import { SaveImage } from "./images.js";
2
+ import { ImportedDeck } from "./pptx.js";
3
+ //#region src/import/pdf.d.ts
4
+ export declare const importPdf: (buffer: Buffer, filename: string, saveImage: SaveImage) => Promise<ImportedDeck>;
5
+ //#endregion
@@ -0,0 +1,49 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { titleOf } from "./titles.js";
3
+ import { PLAIN_THEME } from "./plain-theme.js";
4
+ import { MAX_EXTENT, MAX_SLIDES } from "@pollium/vyva-core/content";
5
+ import { newId } from "@pollium/vyva-core/ids";
6
+ import { getDocumentProxy } from "unpdf";
7
+ //#region src/import/pdf.ts
8
+ const PX_PER_POINT = 2;
9
+ const PDF_TYPE = "application/pdf";
10
+ const slideSizeOf = (width, height) => {
11
+ const scale = Math.min(PX_PER_POINT, MAX_EXTENT / Math.max(width, height));
12
+ return {
13
+ width: Math.max(1, Math.round(width * scale)),
14
+ height: Math.max(1, Math.round(height * scale))
15
+ };
16
+ };
17
+ const opened = (buffer, filename) => getDocumentProxy(new Uint8Array(buffer)).catch((error) => {
18
+ throw new ConvertError("unsupported", `${filename} is a PDF Vyva cannot open`, { cause: error });
19
+ });
20
+ const importPdf = async (buffer, filename, saveImage) => {
21
+ const pdf = await opened(buffer, filename);
22
+ if (pdf.numPages > MAX_SLIDES) throw new ConvertError("too-large", `${filename} has ${pdf.numPages} pages, more than the ${MAX_SLIDES} slides a deck holds`);
23
+ const { width, height } = (await pdf.getPage(1)).getViewport({ scale: 1 });
24
+ const fileId = await saveImage({
25
+ name: filename,
26
+ data: buffer,
27
+ contentType: PDF_TYPE
28
+ });
29
+ if (fileId === null) throw new ConvertError("empty", `${filename} was not stored, so there is nothing to show`);
30
+ return {
31
+ title: titleOf("", filename),
32
+ content: {
33
+ kind: "deck",
34
+ format: slideSizeOf(width, height),
35
+ theme: PLAIN_THEME,
36
+ slides: Array.from({ length: pdf.numPages }, (_, index) => ({
37
+ id: newId(),
38
+ background: {
39
+ type: "pdf-page",
40
+ fileId,
41
+ page: index + 1
42
+ },
43
+ elements: []
44
+ }))
45
+ }
46
+ };
47
+ };
48
+ //#endregion
49
+ export { importPdf };
@@ -0,0 +1,4 @@
1
+ import { DeckTheme } from "@pollium/vyva-core/content";
2
+ //#region src/import/plain-theme.d.ts
3
+ export declare const PLAIN_THEME: DeckTheme;
4
+ //#endregion
@@ -0,0 +1,6 @@
1
+ import { DECK_THEME } from "@pollium/vyva-core/themes";
2
+ //#region src/import/plain-theme.ts
3
+ const { frame: _frame, ...unframed } = DECK_THEME;
4
+ const PLAIN_THEME = unframed;
5
+ //#endregion
6
+ export { PLAIN_THEME };
@@ -1,11 +1,12 @@
1
1
  import { ConvertError } from "../errors.js";
2
2
  import { titleOf } from "./titles.js";
3
3
  import { imageSaver } from "./images.js";
4
+ import { formatFor } from "./deck-format.js";
4
5
  import { all, attr, first, num, textOf } from "./pptx-xml.js";
5
6
  import { familyOf } from "./pptx-theme.js";
6
7
  import PptxPackage from "./pptx-package.js";
7
8
  import SlideReader from "./pptx-slide.js";
8
- import { DECK_SIZES } from "@pollium/vyva-core/content";
9
+ import { DECK_SIZES, MAX_SLIDES } from "@pollium/vyva-core/content";
9
10
  import { unzipSync } from "fflate";
10
11
  import { posix } from "node:path";
11
12
  //#region src/import/pptx.ts
@@ -13,7 +14,6 @@ const EMU_PER_INCH = 914400;
13
14
  const PX_PER_INCH = 144;
14
15
  const DEFAULT_WIDTH = 12192e3;
15
16
  const DEFAULT_HEIGHT = 6858e3;
16
- const MAX_SLIDES = 300;
17
17
  const MAX_IMAGES = 80;
18
18
  const MAX_UNZIPPED_BYTES = 125829120;
19
19
  const IMAGE_TYPES = {
@@ -23,32 +23,6 @@ const IMAGE_TYPES = {
23
23
  gif: "image/gif",
24
24
  webp: "image/webp"
25
25
  };
26
- const FORMATS = [
27
- {
28
- format: "16:9",
29
- ratio: 16 / 9
30
- },
31
- {
32
- format: "4:3",
33
- ratio: 4 / 3
34
- },
35
- {
36
- format: "1:1",
37
- ratio: 1
38
- },
39
- {
40
- format: "a4-landscape",
41
- ratio: 1527 / 1080
42
- },
43
- {
44
- format: "a4-portrait",
45
- ratio: 1080 / 1527
46
- }
47
- ];
48
- const formatFor = (cx, cy) => {
49
- const ratio = cx / Math.max(cy, 1);
50
- return FORMATS.reduce((best, candidate) => Math.abs(candidate.ratio - ratio) < Math.abs(best.ratio - ratio) ? candidate : best).format;
51
- };
52
26
  const plainText = (html) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
53
27
  const deckTitle = (titles, slides, filename) => {
54
28
  const biggest = slides[0]?.elements.filter((element) => element.type === "text" && !element.isLocked).sort((a, b) => b.style.fontSize - a.style.fontSize)[0];
package/dist/import.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { SaveImage } from "./import/images.js";
2
- import { PdfSlides } from "./import/pdf-slides.js";
2
+ import { OfficeEngine } from "./import/office.js";
3
3
  import { Content } from "@pollium/vyva-core/content";
4
4
  //#region src/import.d.ts
5
5
  export declare const MAX_IMPORT_BYTES: number;
6
6
  export interface ImportOptions {
7
7
  saveImage: SaveImage;
8
8
  maxBytes?: number;
9
- pdfSlides?: PdfSlides;
9
+ officeEngine?: OfficeEngine;
10
10
  }
11
11
  export interface ImportedContent {
12
12
  title: string;
package/dist/import.js CHANGED
@@ -1,23 +1,44 @@
1
1
  import { ConvertError } from "./errors.js";
2
- import { importDocx, importMarkdown, importPdf, importPlainText } from "./import/documents.js";
2
+ import { importDocx, importMarkdown, importPlainText } from "./import/documents.js";
3
+ import { imageTypeOf, importImage } from "./import/image.js";
4
+ import { runOffice } from "./import/office.js";
5
+ import { importPdf } from "./import/pdf.js";
3
6
  import { importPptx } from "./import/pptx.js";
4
- import { importPdfSlides, isLandscapePdf } from "./import/pdf-slides.js";
5
- import { unzipSync } from "fflate";
7
+ import { strFromU8, unzipSync } from "fflate";
6
8
  //#region src/import.ts
7
9
  const MAX_IMPORT_BYTES = 26214400;
8
10
  const ZIP_SIGNATURE = 67324752;
9
11
  const PDF_SIGNATURE = "%PDF-";
12
+ const RTF_SIGNATURE = "{\\rtf";
13
+ const OLE_SIGNATURE = Buffer.from("d0cf11e0a1b11ae1", "hex");
10
14
  const TEXT_SAMPLE_BYTES = 65536;
15
+ const MAX_MIMETYPE_BYTES = 128;
11
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
+ }];
12
30
  const zipParts = (buffer) => {
13
31
  if (buffer.length < 4 || buffer.readUInt32LE(0) !== ZIP_SIGNATURE) return null;
14
32
  try {
15
33
  const names = /* @__PURE__ */ new Set();
16
- unzipSync(buffer, { filter: (file) => {
34
+ const read = unzipSync(buffer, { filter: (file) => {
17
35
  names.add(file.name);
18
- return false;
36
+ return file.name === "mimetype" && file.originalSize <= MAX_MIMETYPE_BYTES;
19
37
  } });
20
- return names;
38
+ return {
39
+ names,
40
+ mimetype: read.mimetype === void 0 ? "" : strFromU8(read.mimetype)
41
+ };
21
42
  } catch {
22
43
  return null;
23
44
  }
@@ -33,24 +54,52 @@ const isUtf8Text = (buffer) => {
33
54
  }
34
55
  };
35
56
  const detect = (buffer, filename) => {
36
- const parts = zipParts(buffer);
37
- if (parts !== null) {
38
- if (parts.has("word/document.xml")) return "docx";
39
- 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;
40
62
  }
41
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;
42
68
  if (!isUtf8Text(buffer)) return null;
43
69
  const extension = filename.toLowerCase().split(".").at(-1) ?? "";
44
70
  if (extension === "md" || extension === "markdown") return "markdown";
45
71
  if (extension === "txt") return "text";
46
72
  return null;
47
73
  };
48
- const convert = async (detected, buffer, filename, { saveImage, pdfSlides }) => {
49
- if (detected === "docx") return importDocx(buffer, filename, saveImage);
50
- if (detected === "pptx") return importPptx(buffer, filename, saveImage);
51
- if (detected === "pdf") return pdfSlides !== void 0 && await isLandscapePdf(buffer) ? importPdfSlides(buffer, filename, saveImage, pdfSlides) : importPdf(buffer, filename);
52
- const text = buffer.toString("utf8").replace(BOM, "");
53
- 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 importPdf(buffer, filename, saveImage);
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
+ }
54
103
  };
55
104
  const isEmpty = (content) => content.kind === "document" ? content.blocks.length === 0 : content.slides.length === 0;
56
105
  const importFile = async (data, filename, options) => {
@@ -1,8 +1,8 @@
1
- import { PdfSlides } from "./import/pdf-slides.js";
1
+ import { OfficeEngine } from "./import/office.js";
2
2
  //#region src/libreoffice.d.ts
3
3
  export interface LibreOfficeOptions {
4
4
  command?: string;
5
5
  timeoutMs?: number;
6
6
  }
7
- export declare const libreOffice: ({ command, timeoutMs }?: LibreOfficeOptions) => PdfSlides;
7
+ export declare const libreOffice: ({ command, timeoutMs }?: LibreOfficeOptions) => OfficeEngine;
8
8
  //#endregion
@@ -8,25 +8,24 @@ import { promisify } from "node:util";
8
8
  //#region src/libreoffice.ts
9
9
  const DEFAULT_TIMEOUT_MS = 12e4;
10
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");
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
14
  try {
15
- await writeFile(source, pdf);
15
+ await writeFile(source, file);
16
16
  await run(command, [
17
17
  `-env:UserInstallation=${pathToFileURL(join(folder, "profile")).href}`,
18
18
  "--headless",
19
- "--infilter=impress_pdf_import",
20
19
  "--convert-to",
21
- "pptx",
20
+ to,
22
21
  "--outdir",
23
22
  folder,
24
23
  source
25
24
  ], { timeout: timeoutMs }).catch((error) => {
26
- throw new ConvertError("slides-failed", `LibreOffice at ${command} could not turn the PDF into slides`, { cause: error });
25
+ throw new ConvertError("office-failed", `LibreOffice at ${command} could not turn the ${from} file into ${to}`, { cause: error });
27
26
  });
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 });
27
+ return new Uint8Array(await readFile(join(folder, `source.${to}`)).catch((error) => {
28
+ throw new ConvertError("office-failed", `LibreOffice finished without writing the ${to} file`, { cause: error });
30
29
  }));
31
30
  } finally {
32
31
  await rm(folder, {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pollium/vyva-convert",
3
- "version": "0.2.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.4.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": {
@@ -34,10 +34,11 @@
34
34
  "fflate": "^0.8.3",
35
35
  "htmlparser2": "^10.1.0",
36
36
  "mammoth": "^1.13.0",
37
+ "pdf-lib": "1.17.1",
37
38
  "pptxgenjs": "^4.0.1",
38
39
  "sanitize-html": "^2.18.0",
39
40
  "unpdf": "^1.8.1",
40
- "@pollium/vyva-core": "0.2.0"
41
+ "@pollium/vyva-core": "0.3.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/node": "^26.6.4",
@@ -1,4 +0,0 @@
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
@@ -1,206 +0,0 @@
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 };
@@ -1,7 +0,0 @@
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
@@ -1,21 +0,0 @@
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 };