@pollium/vyva-convert 0.0.0-stage → 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.
Files changed (64) hide show
  1. package/README.md +100 -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 +63 -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 +334 -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/pdf-lines.d.ts +4 -0
  39. package/dist/import/pdf-lines.js +206 -0
  40. package/dist/import/pdf-slides.d.ts +7 -0
  41. package/dist/import/pdf-slides.js +21 -0
  42. package/dist/import/pptx-geometry.d.ts +11 -0
  43. package/dist/import/pptx-geometry.js +74 -0
  44. package/dist/import/pptx-package.d.ts +22 -0
  45. package/dist/import/pptx-package.js +61 -0
  46. package/dist/import/pptx-placeholders.d.ts +11 -0
  47. package/dist/import/pptx-placeholders.js +44 -0
  48. package/dist/import/pptx-slide.d.ts +19 -0
  49. package/dist/import/pptx-slide.js +348 -0
  50. package/dist/import/pptx-theme.d.ts +19 -0
  51. package/dist/import/pptx-theme.js +160 -0
  52. package/dist/import/pptx-xml.d.ts +23 -0
  53. package/dist/import/pptx-xml.js +27 -0
  54. package/dist/import/pptx.d.ts +9 -0
  55. package/dist/import/pptx.js +126 -0
  56. package/dist/import/titles.d.ts +3 -0
  57. package/dist/import/titles.js +6 -0
  58. package/dist/import.d.ts +16 -0
  59. package/dist/import.js +68 -0
  60. package/dist/libreoffice.d.ts +8 -0
  61. package/dist/libreoffice.js +39 -0
  62. package/dist/text-measure.d.ts +5 -0
  63. package/dist/text-measure.js +102 -0
  64. package/package.json +50 -3
@@ -0,0 +1,126 @@
1
+ import { ConvertError } from "../errors.js";
2
+ import { titleOf } from "./titles.js";
3
+ import { imageSaver } from "./images.js";
4
+ import { all, attr, first, num, textOf } from "./pptx-xml.js";
5
+ import { familyOf } from "./pptx-theme.js";
6
+ import PptxPackage from "./pptx-package.js";
7
+ import SlideReader from "./pptx-slide.js";
8
+ import { DECK_SIZES } from "@pollium/vyva-core/content";
9
+ import { unzipSync } from "fflate";
10
+ import { posix } from "node:path";
11
+ //#region src/import/pptx.ts
12
+ const EMU_PER_INCH = 914400;
13
+ const PX_PER_INCH = 144;
14
+ const DEFAULT_WIDTH = 12192e3;
15
+ const DEFAULT_HEIGHT = 6858e3;
16
+ const MAX_SLIDES = 300;
17
+ const MAX_IMAGES = 80;
18
+ const MAX_UNZIPPED_BYTES = 125829120;
19
+ const IMAGE_TYPES = {
20
+ png: "image/png",
21
+ jpg: "image/jpeg",
22
+ jpeg: "image/jpeg",
23
+ gif: "image/gif",
24
+ webp: "image/webp"
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
+ const plainText = (html) => html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
53
+ const deckTitle = (titles, slides, filename) => {
54
+ const biggest = slides[0]?.elements.filter((element) => element.type === "text" && !element.isLocked).sort((a, b) => b.style.fontSize - a.style.fontSize)[0];
55
+ const title = titles.find((candidate) => candidate !== "") ?? (biggest === void 0 ? "" : plainText(biggest.html));
56
+ return titleOf(title, filename);
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
+ };
67
+ const unzipped = (buffer) => {
68
+ let total = 0;
69
+ return unzipSync(buffer, { filter: (file) => {
70
+ total += file.originalSize;
71
+ if (total > MAX_UNZIPPED_BYTES) throw new ConvertError("too-large", `The presentation unzips to more than ${MAX_UNZIPPED_BYTES} bytes`);
72
+ return file.name.startsWith("ppt/") || file.name === "[Content_Types].xml" || file.name.startsWith("docProps/");
73
+ } });
74
+ };
75
+ const importPptx = async (buffer, filename, saveImage) => {
76
+ const files = unzipped(buffer);
77
+ const pptx = new PptxPackage(files);
78
+ const presentation = pptx.document("ppt/presentation.xml");
79
+ if (presentation === void 0) throw new ConvertError("unsupported", `${filename} has no presentation in it`);
80
+ const size = first(presentation, "p:sldSz");
81
+ const cx = num(attr(size, "cx"), DEFAULT_WIDTH);
82
+ const cy = num(attr(size, "cy"), DEFAULT_HEIGHT);
83
+ const format = formatFor(cx, cy);
84
+ const relations = pptx.relationsOf("ppt/presentation.xml");
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);
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
+ };
92
+ const save = imageSaver(saveImage, filename, MAX_IMAGES);
93
+ const saved = /* @__PURE__ */ new Map();
94
+ const reader = new SlideReader({
95
+ scale: DECK_SIZES[format].width / cx,
96
+ fontScale: DECK_SIZES[format].width / (cx / EMU_PER_INCH * PX_PER_INCH),
97
+ theme,
98
+ image: (path) => {
99
+ const known = saved.get(path);
100
+ if (known !== void 0) return known;
101
+ const data = files[path];
102
+ const contentType = IMAGE_TYPES[posix.extname(path).slice(1).toLowerCase()];
103
+ const fileId = data === void 0 || contentType === void 0 ? Promise.resolve(null) : save(data, contentType);
104
+ saved.set(path, fileId);
105
+ return fileId;
106
+ }
107
+ });
108
+ const slides = [];
109
+ const titles = [];
110
+ for (const parts of allParts) {
111
+ const read = await reader.read(parts);
112
+ slides.push(read.slide);
113
+ titles.push(read.title);
114
+ }
115
+ return {
116
+ title: deckTitle(titles, slides, filename),
117
+ content: {
118
+ kind: "deck",
119
+ format,
120
+ theme,
121
+ slides
122
+ }
123
+ };
124
+ };
125
+ //#endregion
126
+ export { importPptx };
@@ -0,0 +1,3 @@
1
+ //#region src/import/titles.d.ts
2
+ export declare const titleOf: (text: string, filename: string) => string;
3
+ //#endregion
@@ -0,0 +1,6 @@
1
+ //#region src/import/titles.ts
2
+ const MAX_TITLE = 200;
3
+ const UNTITLED = "Untitled";
4
+ const titleOf = (text, filename) => (text.trim() || filename.replace(/\.[a-z0-9]+$/i, "").replace(/[_-]+/g, " ").trim() || UNTITLED).slice(0, MAX_TITLE);
5
+ //#endregion
6
+ export { titleOf };
@@ -0,0 +1,16 @@
1
+ import { SaveImage } from "./import/images.js";
2
+ import { PdfSlides } from "./import/pdf-slides.js";
3
+ import { Content } from "@pollium/vyva-core/content";
4
+ //#region src/import.d.ts
5
+ export declare const MAX_IMPORT_BYTES: number;
6
+ export interface ImportOptions {
7
+ saveImage: SaveImage;
8
+ maxBytes?: number;
9
+ pdfSlides?: PdfSlides;
10
+ }
11
+ export interface ImportedContent {
12
+ title: string;
13
+ content: Content;
14
+ }
15
+ export declare const importFile: (data: Uint8Array, filename: string, options: ImportOptions) => Promise<ImportedContent>;
16
+ //#endregion
package/dist/import.js ADDED
@@ -0,0 +1,68 @@
1
+ import { ConvertError } from "./errors.js";
2
+ import { importDocx, importMarkdown, importPdf, importPlainText } from "./import/documents.js";
3
+ import { importPptx } from "./import/pptx.js";
4
+ import { importPdfSlides, isLandscapePdf } from "./import/pdf-slides.js";
5
+ import { unzipSync } from "fflate";
6
+ //#region src/import.ts
7
+ const MAX_IMPORT_BYTES = 26214400;
8
+ const ZIP_SIGNATURE = 67324752;
9
+ const PDF_SIGNATURE = "%PDF-";
10
+ const TEXT_SAMPLE_BYTES = 65536;
11
+ const BOM = /^/;
12
+ const zipParts = (buffer) => {
13
+ if (buffer.length < 4 || buffer.readUInt32LE(0) !== ZIP_SIGNATURE) return null;
14
+ try {
15
+ const names = /* @__PURE__ */ new Set();
16
+ unzipSync(buffer, { filter: (file) => {
17
+ names.add(file.name);
18
+ return false;
19
+ } });
20
+ return names;
21
+ } catch {
22
+ return null;
23
+ }
24
+ };
25
+ const isUtf8Text = (buffer) => {
26
+ const sample = buffer.subarray(0, TEXT_SAMPLE_BYTES);
27
+ if (sample.includes(0)) return false;
28
+ try {
29
+ new TextDecoder("utf-8", { fatal: true }).decode(sample);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ };
35
+ 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;
40
+ }
41
+ if (buffer.subarray(0, 5).toString("latin1") === PDF_SIGNATURE) return "pdf";
42
+ if (!isUtf8Text(buffer)) return null;
43
+ const extension = filename.toLowerCase().split(".").at(-1) ?? "";
44
+ if (extension === "md" || extension === "markdown") return "markdown";
45
+ if (extension === "txt") return "text";
46
+ return null;
47
+ };
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);
54
+ };
55
+ const isEmpty = (content) => content.kind === "document" ? content.blocks.length === 0 : content.slides.length === 0;
56
+ const importFile = async (data, filename, options) => {
57
+ const { maxBytes = MAX_IMPORT_BYTES } = options;
58
+ if (data.length > maxBytes) throw new ConvertError("too-large", `The file is larger than ${maxBytes} bytes`);
59
+ const buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
60
+ const name = filename.replace(/[\\/]/g, "_").slice(-200);
61
+ const detected = detect(buffer, name);
62
+ if (detected === null) throw new ConvertError("unsupported", `${name} is not a file Vyva can import`);
63
+ const imported = await convert(detected, buffer, name, options);
64
+ if (isEmpty(imported.content)) throw new ConvertError("empty", `${name} has nothing to import`);
65
+ return imported;
66
+ };
67
+ //#endregion
68
+ export { MAX_IMPORT_BYTES, importFile };
@@ -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 };
@@ -0,0 +1,5 @@
1
+ import { FontFamily, TextStyle } from "@pollium/vyva-core/content";
2
+ //#region src/text-measure.d.ts
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;
5
+ //#endregion
@@ -0,0 +1,102 @@
1
+ import { FONT_WIDTHS, MEASURED_CHARS } from "./font-widths.js";
2
+ import { CODE_FONT } from "@pollium/vyva-core/code";
3
+ //#region src/text-measure.ts
4
+ const EM = 1e3;
5
+ const BLOCK_GAP_EMS = .35;
6
+ const LIST_INDENT_EMS = 1.15;
7
+ const WIDTH_SAFETY = .97;
8
+ const BLOCK_END = /<\/(?:p|li|h[1-6])>/i;
9
+ const TAG = /<(\/?)([a-z0-9]+)[^>]*>|([^<]+)/gi;
10
+ const BOLD_TAGS = /* @__PURE__ */ new Set([
11
+ "strong",
12
+ "b",
13
+ "h1",
14
+ "h2",
15
+ "h3",
16
+ "h4",
17
+ "h5",
18
+ "h6"
19
+ ]);
20
+ const ENTITIES = {
21
+ amp: "&",
22
+ lt: "<",
23
+ gt: ">",
24
+ quot: "\"",
25
+ apos: "'",
26
+ nbsp: " ",
27
+ "#39": "'"
28
+ };
29
+ const INDEX = new Map([...MEASURED_CHARS].map((char, index) => [char, index]));
30
+ const FALLBACK = INDEX.get("n") ?? 0;
31
+ const decoded = (text) => text.replace(/&(#?\w+);/g, (entity, name) => ENTITIES[name] ?? (name.startsWith("#") ? String.fromCodePoint(Number(name.slice(1))) : entity));
32
+ const glyphsOf = (html, font, isBold) => {
33
+ const glyphs = [];
34
+ let bold = 0;
35
+ let code = 0;
36
+ for (const [, closing, tag, text] of html.matchAll(TAG)) {
37
+ if (text !== void 0) {
38
+ for (const char of decoded(text).replace(/\s+/g, " ")) glyphs.push({
39
+ char,
40
+ font: code > 0 ? CODE_FONT : font,
41
+ isBold: isBold || bold > 0
42
+ });
43
+ continue;
44
+ }
45
+ const step = closing === "/" ? -1 : 1;
46
+ const name = tag.toLowerCase();
47
+ if (BOLD_TAGS.has(name)) bold = Math.max(0, bold + step);
48
+ else if (name === "code") code = Math.max(0, code + step);
49
+ }
50
+ return glyphs;
51
+ };
52
+ const blocksOf = (html, font, isBold) => html.split(BLOCK_END).flatMap((chunk) => {
53
+ const glyphs = glyphsOf(chunk, font, isBold);
54
+ while (glyphs[0]?.char === " ") glyphs.shift();
55
+ while (glyphs.at(-1)?.char === " ") glyphs.pop();
56
+ return glyphs.length === 0 ? [] : [{
57
+ glyphs,
58
+ isItem: /<li\b/i.test(chunk)
59
+ }];
60
+ });
61
+ const widthOf = (glyph, fontSize) => {
62
+ return FONT_WIDTHS[glyph.font][glyph.isBold ? "bold" : "regular"][INDEX.get(glyph.char) ?? FALLBACK] / EM * fontSize;
63
+ };
64
+ const linesOf = (glyphs, fontSize, width) => {
65
+ let lines = 1;
66
+ let line = 0;
67
+ let word = 0;
68
+ let space = 0;
69
+ const place = () => {
70
+ if (word > width) lines = Infinity;
71
+ if (line > 0 && line + space + word > width) {
72
+ lines += 1;
73
+ line = 0;
74
+ }
75
+ line += (line === 0 ? 0 : space) + word;
76
+ word = 0;
77
+ };
78
+ for (const glyph of glyphs) {
79
+ if (glyph.char !== " ") {
80
+ word += widthOf(glyph, fontSize);
81
+ continue;
82
+ }
83
+ place();
84
+ space = widthOf(glyph, fontSize);
85
+ }
86
+ place();
87
+ return lines;
88
+ };
89
+ const textHeight = (html, style, font, width) => {
90
+ const blocks = blocksOf(html, font, style.isBold);
91
+ return blocks.reduce((height, block, index) => {
92
+ const indent = block.isItem ? LIST_INDENT_EMS * style.fontSize : 0;
93
+ const gap = !block.isItem && index === blocks.length - 1 ? 0 : BLOCK_GAP_EMS * style.fontSize;
94
+ return height + linesOf(block.glyphs, style.fontSize, (width - indent) * WIDTH_SAFETY) * style.fontSize * style.lineHeight + gap;
95
+ }, 0);
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
+ };
101
+ //#endregion
102
+ export { textHeight, unwrappedWidth };
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@pollium/vyva-convert",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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.",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Pollium/Vyva.git",
10
+ "directory": "packages/convert"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "exports": {
16
+ "./*": {
17
+ "types": "./dist/*.d.ts",
18
+ "default": "./dist/*.js"
19
+ }
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@fontsource-variable/inter": "^5.3.0",
26
+ "@fontsource-variable/jetbrains-mono": "^5.3.0",
27
+ "@fontsource-variable/lora": "^5.3.0",
28
+ "@fontsource-variable/merriweather": "^5.3.0",
29
+ "@fontsource-variable/montserrat": "^5.3.0",
30
+ "@fontsource-variable/open-sans": "^5.3.0",
31
+ "@fontsource-variable/playfair-display": "^5.3.0",
32
+ "@fontsource-variable/roboto": "^5.3.0",
33
+ "docx": "^9.9.0",
34
+ "fflate": "^0.8.3",
35
+ "htmlparser2": "^10.1.0",
36
+ "mammoth": "^1.13.0",
37
+ "pptxgenjs": "^4.0.1",
38
+ "sanitize-html": "^2.18.0",
39
+ "unpdf": "^1.8.1",
40
+ "@pollium/vyva-core": "0.2.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "^26.6.4",
44
+ "@types/sanitize-html": "^2.16.2"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown",
48
+ "typecheck": "tsc -p tsconfig.json",
49
+ "lint": "oxlint --deny-warnings",
50
+ "test": "vitest run",
51
+ "font-widths": "node scripts/font-widths.ts"
52
+ }
6
53
  }