@pollium/vyva-convert 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +88 -2
  2. package/dist/attachment.d.ts +3 -0
  3. package/dist/attachment.js +8 -0
  4. package/dist/clean-content.d.ts +6 -0
  5. package/dist/clean-content.js +58 -0
  6. package/dist/clean-html.d.ts +3 -0
  7. package/dist/clean-html.js +161 -0
  8. package/dist/errors.d.ts +7 -0
  9. package/dist/errors.js +11 -0
  10. package/dist/export/bundle.d.ts +34 -0
  11. package/dist/export/bundle.js +41 -0
  12. package/dist/export/docx.d.ts +5 -0
  13. package/dist/export/docx.js +382 -0
  14. package/dist/export/fonts.d.ts +16 -0
  15. package/dist/export/fonts.js +37 -0
  16. package/dist/export/html-blocks.d.ts +58 -0
  17. package/dist/export/html-blocks.js +170 -0
  18. package/dist/export/html.d.ts +8 -0
  19. package/dist/export/html.js +31 -0
  20. package/dist/export/image-size.d.ts +8 -0
  21. package/dist/export/image-size.js +83 -0
  22. package/dist/export/pdf.d.ts +28 -0
  23. package/dist/export/pdf.js +84 -0
  24. package/dist/export/pptx.d.ts +5 -0
  25. package/dist/export/pptx.js +310 -0
  26. package/dist/export.d.ts +19 -0
  27. package/dist/export.js +39 -0
  28. package/dist/font-widths.d.ts +8 -0
  29. package/dist/font-widths.js +3430 -0
  30. package/dist/gotenberg.d.ts +8 -0
  31. package/dist/gotenberg.js +39 -0
  32. package/dist/import/documents.d.ts +12 -0
  33. package/dist/import/documents.js +44 -0
  34. package/dist/import/html-to-blocks.d.ts +6 -0
  35. package/dist/import/html-to-blocks.js +58 -0
  36. package/dist/import/images.d.ts +10 -0
  37. package/dist/import/images.js +23 -0
  38. package/dist/import/pptx-package.d.ts +22 -0
  39. package/dist/import/pptx-package.js +61 -0
  40. package/dist/import/pptx-placeholders.d.ts +11 -0
  41. package/dist/import/pptx-placeholders.js +44 -0
  42. package/dist/import/pptx-slide.d.ts +19 -0
  43. package/dist/import/pptx-slide.js +324 -0
  44. package/dist/import/pptx-theme.d.ts +18 -0
  45. package/dist/import/pptx-theme.js +161 -0
  46. package/dist/import/pptx-xml.d.ts +23 -0
  47. package/dist/import/pptx-xml.js +27 -0
  48. package/dist/import/pptx.d.ts +9 -0
  49. package/dist/import/pptx.js +112 -0
  50. package/dist/import/titles.d.ts +3 -0
  51. package/dist/import/titles.js +6 -0
  52. package/dist/import.d.ts +14 -0
  53. package/dist/import.js +66 -0
  54. package/dist/text-measure.d.ts +4 -0
  55. package/dist/text-measure.js +98 -0
  56. package/package.json +49 -3
package/README.md CHANGED
@@ -1,3 +1,89 @@
1
- # Temporary Holding Version
1
+ # @pollium/vyva-convert
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
5
+
6
+ ```sh
7
+ npm install @pollium/vyva-convert
8
+ ```
9
+
10
+ ## Cleaning what clients send
11
+
12
+ ```ts
13
+ import { cleanContent } from '@pollium/vyva-convert/clean-content';
14
+
15
+ const content = cleanContent(body.content);
16
+ ```
17
+
18
+ The cleaner keeps the editor's formatting. It removes scripts, event handlers, unsafe links and any
19
+ image that does not point at a file id. Ids that repeat or are not valid get new ones. Each text box is
20
+ saved as tall as its text. For a single HTML fragment, use `cleanHtml` from
21
+ `@pollium/vyva-convert/clean-html`.
22
+
23
+ ## Exporting
24
+
25
+ ```ts
26
+ import { exportContent, isExportFormat } from '@pollium/vyva-convert/export';
27
+ import { gotenberg } from '@pollium/vyva-convert/gotenberg';
28
+ import { contentDisposition } from '@pollium/vyva-convert/attachment';
29
+
30
+ if(!isExportFormat(request.params.format)) return reply.code(400).send();
31
+
32
+ const file = await exportContent(content, request.params.format, {
33
+ title: 'Subnetting guide',
34
+ language: 'en',
35
+ readImage: async (fileId) => {
36
+ const stored = await files.find(fileId);
37
+ return stored === null ? null : { data: stored.bytes, contentType: stored.contentType };
38
+ },
39
+ pdfEngine: gotenberg({ url: 'http://gotenberg:3000' })
40
+ });
41
+
42
+ reply.header('content-disposition', contentDisposition('Subnetting guide', file.extension)).type(file.contentType).send(file.data);
43
+ ```
44
+
45
+ | Content | Formats |
46
+ |---|---|
47
+ | Document | `pdf`, `docx`, `html` |
48
+ | Deck | `pptx`, `pdf` |
49
+
50
+ - `readImage` is called once for every image the content uses. Return `null` and the export goes
51
+ on without that image.
52
+ - The PDF fonts and images travel with the page, so the PDF engine needs no network access.
53
+ - `pdfEngine` is only needed for `pdf`. It is a function that takes a `PdfJob` (the HTML, the files
54
+ it refers to by name, a footer with Chromium's `pageNumber` and `totalPages` classes, and the
55
+ page size in inches) and returns the PDF. Use `gotenberg` or write your own with Puppeteer or
56
+ Playwright.
57
+ - The `html` export is one self-contained file, with its images and fonts inline.
58
+
59
+ ## Importing
60
+
61
+ ```ts
62
+ import { importFile } from '@pollium/vyva-convert/import';
63
+
64
+ const { title, content } = await importFile(upload.bytes, upload.filename, {
65
+ saveImage: async ({ name, data, contentType }) => (await files.store(name, data, contentType)).id
66
+ });
67
+ ```
68
+
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
73
+ keeps its theme colors and font. What its slide master draws comes in locked, behind the slide's
74
+ own elements.
75
+ - `saveImage` stores each image and returns its file id, which must be a UUID. Return `null` to
76
+ leave the image out. If it throws, the import fails with your error.
77
+ - Files over 25 MB (`maxBytes` changes the limit) and files it cannot read are refused.
78
+
79
+ ## Errors
80
+
81
+ Expected failures are a `ConvertError` from `@pollium/vyva-convert/errors`, with a `code`. Anything else is
82
+ a bug or a broken file, so log it.
83
+
84
+ | Code | Meaning |
85
+ |---|---|
86
+ | `unsupported` | The file is not one Vyva imports, or the content cannot be exported in that format |
87
+ | `too-large` | The file, or what it unzips to, is over the limit |
88
+ | `empty` | The file has nothing to import |
89
+ | `pdf-failed` | The PDF engine could not be reached or answered with an error |
@@ -0,0 +1,3 @@
1
+ //#region src/attachment.d.ts
2
+ export declare const contentDisposition: (title: string, extension: string) => string;
3
+ //#endregion
@@ -0,0 +1,8 @@
1
+ //#region src/attachment.ts
2
+ const ASCII_FALLBACK = "download";
3
+ const NAME_LIMIT = 80;
4
+ const asciiSlug = (title) => title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, NAME_LIMIT) || ASCII_FALLBACK;
5
+ const readable = (title) => title.replace(/[\\/:*?"<>|\r\n]+/g, " ").replace(/\s+/g, " ").trim().slice(0, NAME_LIMIT) || ASCII_FALLBACK;
6
+ const contentDisposition = (title, extension) => `attachment; filename="${asciiSlug(title)}.${extension}"; filename*=UTF-8''${encodeURIComponent(`${readable(title)}.${extension}`)}`;
7
+ //#endregion
8
+ export { contentDisposition };
@@ -0,0 +1,6 @@
1
+ import { Content, DeckContent, DocumentContent } from "@pollium/vyva-core/content";
2
+ //#region src/clean-content.d.ts
3
+ export declare const cleanDocument: (content: DocumentContent) => DocumentContent;
4
+ export declare const cleanDeck: (content: DeckContent) => DeckContent;
5
+ export declare const cleanContent: (content: Content) => Content;
6
+ //#endregion
@@ -0,0 +1,58 @@
1
+ import { cleanHtml } from "./clean-html.js";
2
+ import { textHeight } from "./text-measure.js";
3
+ import { TEXT_SHAPES } from "@pollium/vyva-core/content";
4
+ import { newId } from "@pollium/vyva-core/ids";
5
+ import { withTextHeight } from "@pollium/vyva-core/render";
6
+ //#region src/clean-content.ts
7
+ const ID = /^[A-Za-z0-9_-]{1,40}$/;
8
+ const uniqueIds = (items) => {
9
+ const seen = /* @__PURE__ */ new Set();
10
+ return items.map((item) => {
11
+ const id = ID.test(item.id) && !seen.has(item.id) ? item.id : newId();
12
+ seen.add(id);
13
+ return id === item.id ? item : {
14
+ ...item,
15
+ id
16
+ };
17
+ });
18
+ };
19
+ const cleanElement = (element, font) => {
20
+ if (element.type === "text") {
21
+ const html = cleanHtml(element.html);
22
+ const height = textHeight(html, element.style, font, element.w);
23
+ return Number.isFinite(height) && height > 0 ? withTextHeight({
24
+ ...element,
25
+ html
26
+ }, height) : {
27
+ ...element,
28
+ html
29
+ };
30
+ }
31
+ if (element.type !== "shape" || element.text === void 0) return element;
32
+ const { text, ...bare } = element;
33
+ const html = TEXT_SHAPES.includes(element.shape) ? cleanHtml(text.html) : "";
34
+ return html === "" ? bare : {
35
+ ...bare,
36
+ text: {
37
+ ...text,
38
+ html
39
+ }
40
+ };
41
+ };
42
+ const cleanDocument = (content) => ({
43
+ ...content,
44
+ blocks: uniqueIds(content.blocks).map((block) => ({
45
+ id: block.id,
46
+ html: cleanHtml(block.html)
47
+ })).filter((block) => block.html !== "")
48
+ });
49
+ const cleanDeck = (content) => ({
50
+ ...content,
51
+ slides: uniqueIds(content.slides).map((slide) => ({
52
+ ...slide,
53
+ elements: uniqueIds(slide.elements).map((element) => cleanElement(element, content.theme.font))
54
+ }))
55
+ });
56
+ const cleanContent = (content) => content.kind === "deck" ? cleanDeck(content) : cleanDocument(content);
57
+ //#endregion
58
+ export { cleanContent, cleanDeck, cleanDocument };
@@ -0,0 +1,3 @@
1
+ //#region src/clean-html.d.ts
2
+ export declare const cleanHtml: (html: string) => string;
3
+ //#endregion
@@ -0,0 +1,161 @@
1
+ import sanitize from "sanitize-html";
2
+ //#region src/clean-html.ts
3
+ const COLOR = /^(#[0-9a-fA-F]{3,8}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(,\s*(0|1|0?\.\d+)\s*)?\))$/;
4
+ const FONT_SIZE = /^(\d{1,3}(\.\d{1,2})?)(px|pt|em|rem|%)$/;
5
+ const ALIGN = /^(left|right|center|justify)$/;
6
+ const LINE_HEIGHT = /^(\d(\.\d{1,2})?|\d{2,3}%)$/;
7
+ const WIDTH = /^(\d{1,4}(\.\d{1,2})?)(px|%)$/;
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+ const TEXT_STYLES = {
10
+ color: [COLOR],
11
+ "background-color": [COLOR],
12
+ "font-size": [FONT_SIZE],
13
+ "text-align": [ALIGN],
14
+ "line-height": [LINE_HEIGHT]
15
+ };
16
+ const OPTIONS = {
17
+ allowedTags: [
18
+ "p",
19
+ "h1",
20
+ "h2",
21
+ "h3",
22
+ "h4",
23
+ "strong",
24
+ "b",
25
+ "em",
26
+ "i",
27
+ "u",
28
+ "s",
29
+ "mark",
30
+ "span",
31
+ "a",
32
+ "br",
33
+ "hr",
34
+ "ul",
35
+ "ol",
36
+ "li",
37
+ "blockquote",
38
+ "pre",
39
+ "code",
40
+ "sup",
41
+ "sub",
42
+ "table",
43
+ "colgroup",
44
+ "col",
45
+ "thead",
46
+ "tbody",
47
+ "tr",
48
+ "th",
49
+ "td",
50
+ "img",
51
+ "div",
52
+ "label",
53
+ "input"
54
+ ],
55
+ allowedAttributes: {
56
+ a: [
57
+ "href",
58
+ "target",
59
+ "rel"
60
+ ],
61
+ img: [
62
+ "data-file-id",
63
+ "alt",
64
+ "width",
65
+ "style",
66
+ {
67
+ name: "data-align",
68
+ multiple: false,
69
+ values: [
70
+ "left",
71
+ "center",
72
+ "right"
73
+ ]
74
+ }
75
+ ],
76
+ ol: ["start"],
77
+ ul: ["data-type"],
78
+ li: ["data-type", "data-checked"],
79
+ code: ["class"],
80
+ pre: ["class"],
81
+ th: [
82
+ "colspan",
83
+ "rowspan",
84
+ "colwidth",
85
+ "style"
86
+ ],
87
+ td: [
88
+ "colspan",
89
+ "rowspan",
90
+ "colwidth",
91
+ "style"
92
+ ],
93
+ col: ["style"],
94
+ div: ["data-type", "data-tone"],
95
+ input: [
96
+ "type",
97
+ "checked",
98
+ "disabled"
99
+ ],
100
+ "*": ["style"]
101
+ },
102
+ allowedClasses: {
103
+ code: [/^language-[a-z0-9+#-]{1,30}$/],
104
+ pre: [/^language-[a-z0-9+#-]{1,30}$/]
105
+ },
106
+ allowedStyles: {
107
+ "*": TEXT_STYLES,
108
+ img: { width: [WIDTH] },
109
+ col: { width: [WIDTH] },
110
+ td: {
111
+ ...TEXT_STYLES,
112
+ width: [WIDTH]
113
+ },
114
+ th: {
115
+ ...TEXT_STYLES,
116
+ width: [WIDTH]
117
+ }
118
+ },
119
+ allowedSchemes: [
120
+ "http",
121
+ "https",
122
+ "mailto"
123
+ ],
124
+ allowedSchemesAppliedToAttributes: ["href"],
125
+ allowProtocolRelative: false,
126
+ disallowedTagsMode: "discard",
127
+ nonTextTags: [
128
+ "script",
129
+ "style",
130
+ "textarea",
131
+ "option",
132
+ "noscript",
133
+ "iframe",
134
+ "object",
135
+ "embed",
136
+ "svg",
137
+ "math"
138
+ ],
139
+ exclusiveFilter: (frame) => frame.tag === "img" && !UUID.test(frame.attribs["data-file-id"] ?? "") || frame.tag === "input" && frame.attribs.type !== "checkbox" || frame.tag === "div" && frame.attribs["data-type"] !== void 0 && !["page-break", "callout"].includes(frame.attribs["data-type"]),
140
+ transformTags: {
141
+ a: (tagName, attribs) => ({
142
+ tagName,
143
+ attribs: {
144
+ ...attribs,
145
+ target: "_blank",
146
+ rel: "noopener noreferrer"
147
+ }
148
+ }),
149
+ input: (tagName, attribs) => ({
150
+ tagName,
151
+ attribs: {
152
+ type: attribs.type ?? "",
153
+ ...attribs.checked === void 0 ? {} : { checked: "checked" },
154
+ disabled: "disabled"
155
+ }
156
+ })
157
+ }
158
+ };
159
+ const cleanHtml = (html) => sanitize(html, OPTIONS).trim();
160
+ //#endregion
161
+ export { cleanHtml };
@@ -0,0 +1,7 @@
1
+ //#region src/errors.d.ts
2
+ export type ConvertErrorCode = 'too-large' | 'unsupported' | 'empty' | 'pdf-failed';
3
+ export declare class ConvertError extends Error {
4
+ readonly code: ConvertErrorCode;
5
+ constructor(code: ConvertErrorCode, message: string, options?: ErrorOptions);
6
+ }
7
+ //#endregion
package/dist/errors.js ADDED
@@ -0,0 +1,11 @@
1
+ //#region src/errors.ts
2
+ var ConvertError = class extends Error {
3
+ code;
4
+ constructor(code, message, options) {
5
+ super(message, options);
6
+ this.name = "ConvertError";
7
+ this.code = code;
8
+ }
9
+ };
10
+ //#endregion
11
+ export { ConvertError };
@@ -0,0 +1,34 @@
1
+ import { ImageSize } from "./image-size.js";
2
+ import { Content, FontFamily } from "@pollium/vyva-core/content";
3
+ //#region src/export/bundle.d.ts
4
+ export interface StoredImage {
5
+ data: Uint8Array;
6
+ contentType: string;
7
+ }
8
+ export type ReadImage = (fileId: string) => Promise<StoredImage | null>;
9
+ export interface BundleImage {
10
+ id: string;
11
+ name: string;
12
+ data: Buffer;
13
+ contentType: string;
14
+ extension: string;
15
+ size: ImageSize;
16
+ }
17
+ export interface ExportBundle {
18
+ title: string;
19
+ language: string;
20
+ images: Map<string, BundleImage>;
21
+ fonts: FontFamily[];
22
+ }
23
+ export interface ExportedFile {
24
+ data: Uint8Array;
25
+ contentType: string;
26
+ extension: string;
27
+ }
28
+ export interface BundleOptions {
29
+ title: string;
30
+ language: string;
31
+ readImage: ReadImage;
32
+ }
33
+ export declare const bundleOf: (content: Content, { title, language, readImage }: BundleOptions) => Promise<ExportBundle>;
34
+ //#endregion
@@ -0,0 +1,41 @@
1
+ import { imageSize } from "./image-size.js";
2
+ import { CODE_FONT } from "@pollium/vyva-core/code";
3
+ import { fileIdsOf } from "@pollium/vyva-core/render";
4
+ //#region src/export/bundle.ts
5
+ const EXTENSIONS = {
6
+ "image/png": "png",
7
+ "image/jpeg": "jpg",
8
+ "image/gif": "gif",
9
+ "image/webp": "webp"
10
+ };
11
+ const fontsOf = (content) => {
12
+ if (content.kind === "document") return [content.design.font, CODE_FONT];
13
+ return content.slides.some((slide) => slide.elements.some((element) => element.type === "code")) ? [content.theme.font, CODE_FONT] : [content.theme.font];
14
+ };
15
+ const bundleImage = (id, stored) => {
16
+ const data = Buffer.from(stored.data);
17
+ const extension = EXTENSIONS[stored.contentType] ?? "bin";
18
+ return {
19
+ id,
20
+ name: `${id}.${extension}`,
21
+ data,
22
+ contentType: stored.contentType,
23
+ extension,
24
+ size: imageSize(data, stored.contentType)
25
+ };
26
+ };
27
+ const bundleOf = async (content, { title, language, readImage }) => {
28
+ const ids = fileIdsOf(content);
29
+ const stored = await Promise.all(ids.map((id) => readImage(id)));
30
+ return {
31
+ title,
32
+ language,
33
+ images: new Map(ids.flatMap((id, index) => {
34
+ const image = stored[index];
35
+ return image === null ? [] : [[id, bundleImage(id, image)]];
36
+ })),
37
+ fonts: fontsOf(content)
38
+ };
39
+ };
40
+ //#endregion
41
+ export { bundleOf };
@@ -0,0 +1,5 @@
1
+ import { ExportBundle, ExportedFile } from "./bundle.js";
2
+ import { DocumentContent } from "@pollium/vyva-core/content";
3
+ //#region src/export/docx.d.ts
4
+ export declare const buildDocx: (content: DocumentContent, bundle: ExportBundle) => Promise<ExportedFile>;
5
+ //#endregion