palette-docs 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.
package/NOTICE.md ADDED
@@ -0,0 +1,8 @@
1
+ # Notice
2
+
3
+ - `vendor/hwpx/blank.hwpx` is the blank document template from hwpxlib (https://github.com/neolord0/hwpxlib,
4
+ `testFile/tool`), Apache License 2.0. New HWPX files are built on it so 한글 opens them as native documents.
5
+ - The HWP 5.0 reader, HWPX reader/writer and the DOCX/XLSX writers were first written for Palette Island
6
+ (same author) and ported to Node here.
7
+ - Dependencies keep their own licences: @modelcontextprotocol/sdk, fast-xml-parser, heic-convert, jszip, mammoth,
8
+ pdf-parse, pptxgenjs, sharp (Apache-2.0; bundles libvips, LGPL-3.0), turndown, turndown-plugin-gfm, xlsx (Apache-2.0), zod.
package/README.md CHANGED
@@ -1,3 +1,40 @@
1
- # Temporary Holding Version
1
+ # Palette Docs
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
+ Documents and images for Palette Code: an MCP server (`palette-docs mcp`) and a CLI, no native toolchain needed.
4
+
5
+ **Read** (markdown out, tables kept): `hwp` `hwpx` `docx` `doc` `rtf` `odt` `xlsx` `xlsm` `xls` `ods` `csv` `tsv` `pptx`
6
+ `ppt` `odp` `pdf` `html` `txt` `md` `json` `xml` `yaml`, and `zip` (expands up to 20 files). Up to 20 files per call.
7
+
8
+ **Images as references**: `png` `jpg` `webp` `gif` `bmp` `tiff` `heic` `svg` `avif` → EXIF-rotated, resized to 1568 px,
9
+ returned as an image block the vision seats (`palette-llm-max`, `palette-llm-fast`) can see. Images embedded in
10
+ hwp/hwpx/docx/pptx/xlsx/zip can be pulled out as files.
11
+
12
+ **Create** from markdown (headings, paragraphs, bullets, numbered lists, tables, code): `docx` `hwpx` `xlsx` `pptx`
13
+ `md` `txt` `csv` `html` natively; `pdf` `doc` `xls` `ppt` `odt` `ods` `odp` `rtf` through LibreOffice when it is
14
+ installed. `hwp` (binary 5.0) cannot be written: create `hwpx`, which 한글 opens as a native document (the file starts
15
+ from Hancom's own blank template, with real tables, shaded header rows and repeated headers).
16
+
17
+ Fonts: `font` picks the family for docx/xlsx/pptx/html (default 맑은 고딕 / Malgun Gothic; Noto Sans KR, Apple SD
18
+ Gothic Neo, 나눔고딕 work the same way). HWPX keeps Hancom's default faces.
19
+
20
+ ```bash
21
+ npm i -g palette-docs
22
+ palette-docs read 제안서.hwp # markdown on stdout
23
+ palette-docs create 보고서.hwpx 보고서.md --title "3분기 보고서"
24
+ palette-docs create 보고서.pdf 보고서.md # needs LibreOffice
25
+ palette-docs convert 제안서.hwpx docx
26
+ palette-docs image IMG_0042.heic # IMG_0042.normalized.jpg
27
+ palette-docs formats
28
+ ```
29
+
30
+ Palette Code pre-configures the server as `docs` in `palette-code.json`:
31
+
32
+ ```json
33
+ "docs": { "type": "local", "command": ["palette-docs", "mcp"], "enabled": true, "codemode": false }
34
+ ```
35
+
36
+ Tools: `read_document`, `read_documents`, `read_image`, `extract_images`, `create_document`, `convert_document`,
37
+ `list_formats`. Tests: `node test/run.mjs` (real HWP/HWPX samples, every writer read back, images, zip, LibreOffice,
38
+ MCP round trip).
39
+
40
+ MIT. See `NOTICE.md`.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "palette-docs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Palette Docs: lets Palette Code read HWP/HWPX/DOCX/XLSX/PPTX/PDF/ZIP and images, and create DOCX/HWPX/XLSX/PPTX (PDF and legacy formats via LibreOffice). MCP server + CLI.",
5
+ "license": "MIT",
6
+ "dependencies": {
7
+ "@modelcontextprotocol/sdk": "1.32.1",
8
+ "fast-xml-parser": "5.11.2",
9
+ "heic-convert": "2.1.0",
10
+ "jszip": "3.10.2",
11
+ "mammoth": "1.13.0",
12
+ "pdf-parse": "2.4.5",
13
+ "pptxgenjs": "4.0.1",
14
+ "sharp": "0.35.5",
15
+ "turndown": "7.2.0",
16
+ "turndown-plugin-gfm": "1.0.2",
17
+ "xlsx": "0.18.5",
18
+ "zod": "^4.6.7"
19
+ },
20
+ "type": "module",
21
+ "bin": {
22
+ "palette-docs": "src/index.js",
23
+ "pdocs": "src/index.js"
24
+ },
25
+ "files": [
26
+ "src/",
27
+ "vendor/",
28
+ "skills/",
29
+ "README.md",
30
+ "NOTICE.md"
31
+ ],
32
+ "homepage": "https://llm.pltt.dev/docs",
33
+ "engines": {
34
+ "node": ">=18.17"
35
+ }
36
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: palette-docs
3
+ description: Reading and creating office documents and images with Palette Docs (MCP server "docs"): HWP/HWPX/DOCX/XLSX/PPTX/PDF/ZIP inputs, image references (png/jpg/webp/heic/svg/…), and DOCX/HWPX/XLSX/PPTX/PDF outputs from markdown. Use when a user attaches or names a document, spreadsheet, deck, PDF, archive or image, or asks for a 한글/Word/Excel/PowerPoint/PDF file.
4
+ ---
5
+
6
+ # Palette Docs
7
+
8
+ ## Reading
9
+ - One file: `read_document(path)`. Several (max 20): `read_documents(paths)`. A `.zip` is expanded (first 20 readable files).
10
+ - The result is markdown: sheets and slides are headed (`## Sheet`, `## Slide 3`), tables are markdown tables. Quote numbers from it exactly.
11
+ - Long files are truncated at `max_chars` (default 60k); raise it or ask for a section rather than guessing.
12
+ - `.hwp` encrypted documents cannot be read; say so and ask for an unlocked copy or HWPX.
13
+ - Legacy `.doc/.ppt/.xls/.rtf/.odt` need LibreOffice on the machine (`list_formats` shows `libreoffice: true/false`).
14
+
15
+ ## Images as references
16
+ - Use `read_image(path)` for any picture (png, jpg, webp, gif, bmp, tiff, heic, svg, avif); it returns the image to the model. Needs a vision seat: `palette-llm-max` or `palette-llm-fast`.
17
+ - Pictures inside a document: `extract_images(path)` then `read_image` on the ones that matter.
18
+
19
+ ## Creating
20
+ - Write the content as markdown first (headings `#`/`##`, bullets, numbered lists, pipe tables, fenced code), then
21
+ `create_document({ path, markdown, title, font? })`; the extension picks the format: `.docx .hwpx .xlsx .pptx .md .csv .html`,
22
+ and `.pdf .doc .xls .ppt .odt .ods .odp .rtf` when LibreOffice is installed.
23
+ - Korean government / public-sector / 한글 users: create `.hwpx` (한글 native). `.hwp` binary export is not available; say so instead of promising it.
24
+ - Spreadsheets: each markdown table becomes a sheet named after the heading above it; percentages and numbers become real cells.
25
+ - Decks: each `##` heading becomes a slide; keep 7 bullets or fewer per heading; tables get their own slide.
26
+ - `font` (docx/xlsx/pptx/html): default 맑은 고딕. Use the user's font if they name one.
27
+ - Put outputs next to the source or where the user said; report the absolute path.
28
+
29
+ ## Converting
30
+ - `convert_document({ path, to })` — e.g. hwpx→docx, docx→pdf, xlsx→csv, pptx→pdf. Native when possible, LibreOffice otherwise.
package/src/blocks.js ADDED
@@ -0,0 +1,118 @@
1
+ // Markdown -> blocks shared by every writer.
2
+ // [{type:'h',level,text} | {type:'p',text} | {type:'li',n?,text} | {type:'table',rows}]
3
+ // Ported from Palette Island ui/export.js (same owner); chart blocks are not carried over.
4
+
5
+ export const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;")
6
+
7
+ export const plain = (s) =>
8
+ String(s)
9
+ .replace(/\*\*(.+?)\*\*/g, "$1")
10
+ .replace(/(^|[^\\])\*(.+?)\*/g, "$1$2")
11
+ .replace(/`([^`]+)`/g, "$1")
12
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
13
+ .replace(/\\([*_])/g, "$1")
14
+ .trim()
15
+
16
+ export function blocksOf(md) {
17
+ const src = String(md).replace(/<details>[\s\S]*?<\/details>/g, "")
18
+ const out = []
19
+ const lines = src.split("\n")
20
+ let inFence = false
21
+ let fence = []
22
+ for (let i = 0; i < lines.length; i++) {
23
+ const raw = lines[i]
24
+ if (/^\s*```/.test(raw)) {
25
+ if (inFence) {
26
+ out.push({ type: "code", text: fence.join("\n") })
27
+ fence = []
28
+ }
29
+ inFence = !inFence
30
+ continue
31
+ }
32
+ if (inFence) {
33
+ fence.push(raw)
34
+ continue
35
+ }
36
+ const l = raw.replace(/^\s*>\s?/, "")
37
+ if (/^\s*([-*_])\1{2,}\s*$/.test(l)) continue
38
+ if (/^\s*\|.*\|\s*$/.test(l)) {
39
+ const rows = []
40
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
41
+ if (!/^\s*\|[\s:|-]+\|\s*$/.test(lines[i]))
42
+ rows.push(
43
+ lines[i]
44
+ .trim()
45
+ .replace(/^\||\|$/g, "")
46
+ .split("|")
47
+ .map((c) => plain(c)),
48
+ )
49
+ i++
50
+ }
51
+ i--
52
+ if (rows.length) out.push({ type: "table", rows })
53
+ continue
54
+ }
55
+ const h = l.match(/^(#{1,4})\s+(.*)/)
56
+ if (h) {
57
+ out.push({ type: "h", level: h[1].length, text: plain(h[2]) })
58
+ continue
59
+ }
60
+ const o = l.match(/^\s*(\d+)[.)]\s+(.*)/)
61
+ if (o) {
62
+ out.push({ type: "li", n: +o[1], text: plain(o[2]) })
63
+ continue
64
+ }
65
+ const b = l.match(/^\s*[-*•]\s+(.*)/)
66
+ if (b) {
67
+ out.push({ type: "li", text: plain(b[1]) })
68
+ continue
69
+ }
70
+ if (/^\*\*[^*]+\*\*:?\s*$/.test(l.trim())) {
71
+ out.push({ type: "h", level: 3, text: plain(l) })
72
+ continue
73
+ }
74
+ if (l.trim()) out.push({ type: "p", text: plain(l) })
75
+ }
76
+ if (inFence && fence.length) out.push({ type: "code", text: fence.join("\n") })
77
+ return out
78
+ }
79
+
80
+ export const isNum = (v) => /^[-+(]?[₩$]?\s?[\d,]+(\.\d+)?\s?(%|원|천원|만원|억원|백만원|배|x)?\)?$/.test(String(v).trim())
81
+
82
+ export const today = () => {
83
+ const d = new Date()
84
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
85
+ }
86
+
87
+ /** Columns whose body cells are mostly numbers: right-aligned, header included. */
88
+ export const numCols = (rows) => {
89
+ const body = rows.slice(1)
90
+ const n = Math.max(0, ...rows.map((r) => r.length))
91
+ return Array.from({ length: n }, (_, ci) => {
92
+ const v = body.map((r) => r[ci]).filter((c) => c != null && String(c).trim())
93
+ return v.length > 0 && v.filter(isNum).length / v.length >= 0.6
94
+ })
95
+ }
96
+
97
+ export const marker = (k) => (k.n != null ? `${k.n}.` : "•")
98
+
99
+ /** Blocks back to markdown (used for md/txt outputs and for the read side). */
100
+ export function toMarkdown(blocks) {
101
+ const out = []
102
+ for (const k of blocks) {
103
+ if (k.type === "h") out.push(`${"#".repeat(k.level)} ${k.text}`, "")
104
+ else if (k.type === "li") out.push(`${k.n != null ? `${k.n}.` : "-"} ${k.text}`)
105
+ else if (k.type === "p") out.push(k.text, "")
106
+ else if (k.type === "code") out.push("```", k.text, "```", "")
107
+ else if (k.type === "table") out.push(mdTable(k.rows), "")
108
+ }
109
+ return out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n"
110
+ }
111
+
112
+ export function mdTable(rows) {
113
+ if (!rows.length) return ""
114
+ const n = Math.max(...rows.map((r) => r.length))
115
+ const cell = (c) => String(c ?? "").replace(/\|/g, "\\|").replace(/\n+/g, " ").trim()
116
+ const line = (r) => `| ${Array.from({ length: n }, (_, i) => cell(r[i])).join(" | ")} |`
117
+ return [line(rows[0]), `| ${Array.from({ length: n }, () => "---").join(" | ")} |`, ...rows.slice(1).map(line)].join("\n")
118
+ }
package/src/hwp.js ADDED
@@ -0,0 +1,205 @@
1
+ // Hangul word processor files, read with no native code.
2
+ // .hwpx a zip of XML: Contents/section*.xml; paragraphs <hp:p>, text <hp:t>, tables <hp:tbl>, pictures <hp:pic>
3
+ // .hwp HWP 5.x: an OLE2 compound file; BodyText/Section* streams, raw-deflated when the FileHeader says so,
4
+ // made of records whose tag 67 (HWPTAG_PARA_TEXT) carries UTF-16LE text with control characters.
5
+ // Ported from Palette Island ui/hwp.js (same owner) to Node: zlib instead of DecompressionStream, fast-xml-parser
6
+ // instead of DOMParser, and tables are rendered instead of flattened.
7
+ import zlib from "node:zlib"
8
+ import JSZip from "jszip"
9
+ import { XMLParser } from "fast-xml-parser"
10
+ import { mdTable } from "./blocks.js"
11
+
12
+ const naturalSort = (a, b) => a.localeCompare(b, undefined, { numeric: true })
13
+
14
+ // ---------- HWPX ----------
15
+ const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, attributeNamePrefix: "@_", trimValues: false, parseTagValue: false })
16
+
17
+ const tagOf = (node) => Object.keys(node).find((k) => k !== ":@")
18
+ const kids = (node) => node[tagOf(node)] || []
19
+
20
+ /** Text of every <hp:t> below `node`, skipping nested tables (they are rendered on their own). */
21
+ function textOf(node, textTag, skipTags) {
22
+ const tag = tagOf(node)
23
+ if (tag === "#text") return String(node["#text"])
24
+ if (skipTags.has(tag)) return ""
25
+ if (tag === "hp:tab") return "\t"
26
+ if (tag === "hp:lineBreak") return "\n"
27
+ if (!Array.isArray(node[tag])) return ""
28
+ return node[tag].map((c) => textOf(c, textTag, skipTags)).join("")
29
+ }
30
+
31
+ function findAll(node, tag, out = [], stopAt = new Set()) {
32
+ const t = tagOf(node)
33
+ if (t === tag) out.push(node)
34
+ if (stopAt.has(t) && t !== tag) return out
35
+ if (Array.isArray(node[t])) for (const c of node[t]) findAll(c, tag, out, stopAt)
36
+ return out
37
+ }
38
+
39
+ /** One paragraph (any nesting) -> markdown lines; tables inside the paragraph come out as markdown tables. */
40
+ function renderPara(p, opts) {
41
+ const { textTag, tableTag, rowTag, cellTag, paraTag } = opts
42
+ const skip = new Set([tableTag, "hp:pic", "hp:secPr", "hp:ctrl", "hp:container"])
43
+ const lines = []
44
+ const text = textOf(p, textTag, skip).replace(/\n+$/, "")
45
+ if (text.trim()) lines.push(text)
46
+ for (const tbl of findAll(p, tableTag, [], new Set([tableTag]))) {
47
+ const rows = []
48
+ for (const tr of findAll(tbl, rowTag, [], new Set([rowTag]))) {
49
+ const cells = []
50
+ for (const tc of findAll(tr, cellTag, [], new Set([cellTag]))) {
51
+ const paras = findAll(tc, paraTag, [], new Set([tableTag]))
52
+ const inner = paras.length ? paras : [tc]
53
+ cells.push(inner.map((ip) => renderPara(ip, opts).join(" ")).filter(Boolean).join(" "))
54
+ }
55
+ rows.push(cells)
56
+ }
57
+ if (rows.length) lines.push("", mdTable(rows), "")
58
+ }
59
+ return lines
60
+ }
61
+
62
+ export async function hwpxRead(buffer) {
63
+ const zip = await JSZip.loadAsync(buffer)
64
+ const names = Object.keys(zip.files).filter((f) => /^Contents\/section\d+\.xml$/i.test(f)).sort(naturalSort)
65
+ const parts = []
66
+ const opts = { textTag: "hp:t", tableTag: "hp:tbl", rowTag: "hp:tr", cellTag: "hp:tc", paraTag: "hp:p" }
67
+ for (const f of names) {
68
+ const tree = parser.parse(await zip.file(f).async("string"))
69
+ const sec = findAll({ root: tree }, "hs:sec")[0]
70
+ const top = sec ? kids(sec).filter((n) => tagOf(n) === "hp:p") : findAll({ root: tree }, "hp:p")
71
+ const lines = []
72
+ for (const p of top) lines.push(...renderPara(p, opts))
73
+ parts.push(lines.join("\n").replace(/\n{3,}/g, "\n\n").trim())
74
+ }
75
+ const images = Object.keys(zip.files).filter((f) => /^BinData\//i.test(f) && !zip.files[f].dir)
76
+ let title = ""
77
+ const hpf = zip.file("Contents/content.hpf")
78
+ if (hpf) title = ((await hpf.async("string")).match(/<opf:title>([^<]*)<\/opf:title>/) || [])[1] || ""
79
+ return { text: parts.join("\n\n"), sections: names.length, images, title }
80
+ }
81
+
82
+ export async function hwpxImages(buffer) {
83
+ const zip = await JSZip.loadAsync(buffer)
84
+ const out = []
85
+ for (const f of Object.keys(zip.files).filter((f) => /^BinData\//i.test(f) && !zip.files[f].dir))
86
+ out.push({ name: f.split("/").pop(), data: await zip.file(f).async("nodebuffer") })
87
+ return out
88
+ }
89
+
90
+ // ---------- minimal OLE2 / CFB reader ----------
91
+ function cfb(buf) {
92
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
93
+ const u8 = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
94
+ if (dv.getUint32(0, true) !== 0xe011cfd0 || dv.getUint32(4, true) !== 0xe11ab1a1) throw new Error("not an OLE file")
95
+ const ss = 1 << dv.getUint16(30, true), mss = 1 << dv.getUint16(32, true)
96
+ const nFat = dv.getUint32(44, true), dirStart = dv.getUint32(48, true), cutoff = dv.getUint32(56, true)
97
+ const miniFatStart = dv.getUint32(60, true), nMiniFat = dv.getUint32(64, true), difatStart = dv.getUint32(68, true), nDifat = dv.getUint32(72, true)
98
+ const sector = (n) => u8.subarray(512 + n * ss, 512 + (n + 1) * ss)
99
+ const u32s = (bytes) => { const out = []; const d = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); for (let i = 0; i + 4 <= bytes.byteLength; i += 4) out.push(d.getUint32(i, true)); return out }
100
+ let difat = []
101
+ for (let i = 0; i < 109; i++) difat.push(dv.getUint32(76 + i * 4, true))
102
+ let ds = difatStart
103
+ for (let k = 0; k < nDifat && ds < 0xfffffffa; k++) { const e = u32s(sector(ds)); difat = difat.concat(e.slice(0, -1)); ds = e[e.length - 1] }
104
+ const fat = []
105
+ for (const s of difat.slice(0, nFat)) if (s < 0xfffffffa) fat.push(...u32s(sector(s)))
106
+ const chain = (start, table) => { const out = []; let s = start; let guard = 0; while (s < 0xfffffffa && guard++ < 1e6) { out.push(s); s = table[s] } return out }
107
+ const readChain = (start) => { const secs = chain(start, fat); const out = new Uint8Array(secs.length * ss); secs.forEach((s, i) => out.set(sector(s), i * ss)); return out }
108
+ const dirBytes = readChain(dirStart)
109
+ const entries = []
110
+ for (let off = 0; off + 128 <= dirBytes.length; off += 128) {
111
+ const d = new DataView(dirBytes.buffer, dirBytes.byteOffset + off, 128)
112
+ const nlen = d.getUint16(64, true), type = d.getUint8(66)
113
+ if (!type) continue
114
+ let name = ""
115
+ for (let i = 0; i + 1 < nlen - 1; i += 2) name += String.fromCharCode(d.getUint16(i, true))
116
+ entries.push({ idx: off / 128, name, type, left: d.getUint32(68, true), right: d.getUint32(72, true), child: d.getUint32(76, true), start: d.getUint32(116, true), size: d.getUint32(120, true) })
117
+ }
118
+ const byIdx = {}
119
+ entries.forEach((e) => (byIdx[e.idx] = e))
120
+ const root = entries.find((e) => e.type === 5)
121
+ const miniStream = root && root.size ? readChain(root.start) : new Uint8Array(0)
122
+ const miniFat = []
123
+ for (const s of chain(miniFatStart, fat).slice(0, nMiniFat)) miniFat.push(...u32s(sector(s)))
124
+ const readStream = (e) => {
125
+ if (e.size >= cutoff) return readChain(e.start).subarray(0, e.size)
126
+ const secs = chain(e.start, miniFat)
127
+ const out = new Uint8Array(secs.length * mss)
128
+ secs.forEach((s, i) => out.set(miniStream.subarray(s * mss, (s + 1) * mss), i * mss))
129
+ return out.subarray(0, e.size)
130
+ }
131
+ const siblings = (idx) => { const out = []; const walk = (i) => { const e = byIdx[i]; if (!e) return; walk(e.left); out.push(e); walk(e.right) }; walk(idx); return out }
132
+ const find = (path) => { let scope = root ? siblings(root.child) : []; let e = null; for (const seg of path) { e = scope.find((x) => x.name === seg); if (!e) return null; scope = siblings(e.child) } return e }
133
+ const list = (storage) => { const e = find([storage]); return e ? siblings(e.child).filter((x) => x.type === 2) : [] }
134
+ return { find, list, readStream }
135
+ }
136
+
137
+ /** HWP sections are raw deflate streams that often carry trailing bytes; keep what inflates. */
138
+ function inflateRaw(bytes) {
139
+ try {
140
+ return zlib.inflateRawSync(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), { finishFlush: zlib.constants.Z_SYNC_FLUSH })
141
+ } catch (e) {
142
+ return zlib.inflateRawSync(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength), { finishFlush: zlib.constants.Z_FULL_FLUSH })
143
+ }
144
+ }
145
+
146
+ function paraText(rec) {
147
+ const n = rec.byteLength >> 1
148
+ const d = new DataView(rec.buffer, rec.byteOffset, n * 2)
149
+ let out = ""
150
+ let j = 0
151
+ while (j < n) {
152
+ const c = d.getUint16(j * 2, true)
153
+ if (c >= 32) { out += String.fromCharCode(c); j += 1 }
154
+ else if (c === 10 || c === 13) { out += "\n"; j += 1 }
155
+ else if (c === 30 || c === 31) { out += " "; j += 1 }
156
+ else if (c === 0 || (c >= 24 && c <= 29)) { j += 1 }
157
+ else { j += 8 } // inline (4-9, 19-20) and extended (1-3, 11-12, 14-18, 21-23) controls occupy 8 code units
158
+ }
159
+ return out
160
+ }
161
+
162
+ function hwpOpen(buffer) {
163
+ const doc = cfb(buffer)
164
+ const header = doc.find(["FileHeader"])
165
+ if (!header) throw new Error("HWP header missing")
166
+ const hb = doc.readStream(header)
167
+ const flags = new DataView(hb.buffer, hb.byteOffset, hb.byteLength).getUint32(36, true)
168
+ if (flags & 2) throw new Error("encrypted HWP documents cannot be read")
169
+ return { doc, compressed: !!(flags & 1), distribution: !!(flags & 4) }
170
+ }
171
+
172
+ export function hwpRead(buffer) {
173
+ const { doc, compressed } = hwpOpen(buffer)
174
+ const sections = doc.list("BodyText").filter((e) => /^Section\d+$/.test(e.name)).sort((a, b) => Number(a.name.slice(7)) - Number(b.name.slice(7)))
175
+ const out = []
176
+ for (const s of sections) {
177
+ let data = doc.readStream(s)
178
+ if (compressed) { try { data = inflateRaw(data) } catch { continue } }
179
+ const dv = new DataView(data.buffer, data.byteOffset, data.byteLength)
180
+ let i = 0
181
+ const paras = []
182
+ while (i + 4 <= data.byteLength) {
183
+ const h = dv.getUint32(i, true); i += 4
184
+ const tag = h & 0x3ff
185
+ let size = (h >>> 20) & 0xfff
186
+ if (size === 0xfff) { if (i + 4 > data.byteLength) break; size = dv.getUint32(i, true); i += 4 }
187
+ if (tag === 67) paras.push(paraText(data.subarray(i, i + size)))
188
+ i += size
189
+ }
190
+ out.push(paras.map((p) => p.replace(/\n+$/, "")).filter((p) => p.trim()).join("\n"))
191
+ }
192
+ const images = doc.list("BinData").map((e) => e.name)
193
+ return { text: out.join("\n\n"), sections: sections.length, images }
194
+ }
195
+
196
+ export function hwpImages(buffer) {
197
+ const { doc, compressed } = hwpOpen(buffer)
198
+ const out = []
199
+ for (const e of doc.list("BinData")) {
200
+ let data = doc.readStream(e)
201
+ if (compressed) { try { data = inflateRaw(data) } catch { continue } }
202
+ out.push({ name: e.name, data: Buffer.from(data.buffer, data.byteOffset, data.byteLength) })
203
+ }
204
+ return out
205
+ }
package/src/image.js ADDED
@@ -0,0 +1,85 @@
1
+ // Images as references: any common format -> a model-ready PNG/JPEG (resized, EXIF-rotated), and images embedded in
2
+ // documents pulled out as files.
3
+ import fs from "node:fs"
4
+ import os from "node:os"
5
+ import path from "node:path"
6
+ import JSZip from "jszip"
7
+ import { hwpImages, hwpxImages } from "./hwp.js"
8
+ import { extOf } from "./read.js"
9
+
10
+ const PHOTO = new Set(["jpg", "jpeg", "heic", "heif", "avif"])
11
+
12
+ export async function normalizeImage(file, { maxSide = 1568 } = {}) {
13
+ if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`)
14
+ const sharp = (await import("sharp")).default
15
+ const ext = extOf(file)
16
+ let input = fs.readFileSync(file)
17
+ let decodedVia = "sharp"
18
+ let pipeline
19
+ if (ext === "heic" || ext === "heif") {
20
+ // prebuilt sharp ships libheif without an HEVC decoder, so iPhone HEIC always goes through heic-convert (wasm libde265)
21
+ const convert = (await import("heic-convert")).default
22
+ input = Buffer.from(await convert({ buffer: input, format: "JPEG", quality: 0.92 }))
23
+ decodedVia = "heic-convert"
24
+ pipeline = sharp(input)
25
+ } else if (ext === "bmp" || input.slice(0, 2).toString() === "BM") {
26
+ // libvips has no BMP loader in the prebuilt binaries: decode uncompressed 24/32-bit BMP here (the common case)
27
+ const raw = decodeBmp(input)
28
+ decodedVia = "bmp-decoder"
29
+ pipeline = sharp(raw.data, { raw: { width: raw.width, height: raw.height, channels: 4 } })
30
+ } else {
31
+ pipeline = sharp(input, ext === "svg" ? { density: 192 } : {})
32
+ await pipeline.metadata()
33
+ }
34
+ const meta = await pipeline.metadata()
35
+ const photo = PHOTO.has(ext) || meta.format === "jpeg"
36
+ let out = pipeline.rotate().resize({ width: maxSide, height: maxSide, fit: "inside", withoutEnlargement: true })
37
+ out = photo ? out.jpeg({ quality: 88 }) : out.png({ compressionLevel: 8 })
38
+ const data = await out.toBuffer()
39
+ const outMeta = await sharp(data).metadata()
40
+ return { data, mimeType: photo ? "image/jpeg" : "image/png", width: outMeta.width, height: outMeta.height, original: { format: meta.format || ext, width: meta.width, height: meta.height, bytes: input.length }, decodedVia }
41
+ }
42
+
43
+ /** Uncompressed 24/32-bit BMP (BITMAPINFOHEADER, BI_RGB or BI_BITFIELDS with the default masks) -> RGBA. */
44
+ function decodeBmp(buf) {
45
+ if (buf.slice(0, 2).toString() !== "BM") throw new Error("not a BMP file")
46
+ const off = buf.readUInt32LE(10), hdr = buf.readUInt32LE(14)
47
+ const width = buf.readInt32LE(18), heightRaw = buf.readInt32LE(22), bpp = buf.readUInt16LE(28), comp = buf.readUInt32LE(30)
48
+ if (hdr < 40 || ![24, 32].includes(bpp) || ![0, 3].includes(comp)) throw new Error(`unsupported BMP (${bpp}-bit, compression ${comp}); convert it to PNG first`)
49
+ const height = Math.abs(heightRaw), topDown = heightRaw < 0
50
+ const stride = Math.floor((bpp * width + 31) / 32) * 4
51
+ const data = Buffer.alloc(width * height * 4)
52
+ for (let y = 0; y < height; y++) {
53
+ const srcRow = off + (topDown ? y : height - 1 - y) * stride
54
+ for (let x = 0; x < width; x++) {
55
+ const s = srcRow + x * (bpp / 8), d = (y * width + x) * 4
56
+ data[d] = buf[s + 2]; data[d + 1] = buf[s + 1]; data[d + 2] = buf[s]; data[d + 3] = bpp === 32 ? buf[s + 3] || 255 : 255
57
+ }
58
+ }
59
+ return { width, height, data }
60
+ }
61
+
62
+ const OOXML_MEDIA = { docx: /^word\/media\//, pptx: /^ppt\/media\//, xlsx: /^xl\/media\// }
63
+
64
+ export async function extractImages(file, outDir) {
65
+ if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`)
66
+ const ext = extOf(file)
67
+ const dir = outDir || fs.mkdtempSync(path.join(os.tmpdir(), "palette-docs-img-"))
68
+ fs.mkdirSync(dir, { recursive: true })
69
+ let items = []
70
+ if (ext === "hwp") items = hwpImages(fs.readFileSync(file))
71
+ else if (ext === "hwpx") items = await hwpxImages(fs.readFileSync(file))
72
+ else if (OOXML_MEDIA[ext] || ext === "zip") {
73
+ const zip = await JSZip.loadAsync(fs.readFileSync(file))
74
+ const re = OOXML_MEDIA[ext] || /\.(png|jpe?g|gif|webp|bmp|tiff?|heic|svg)$/i
75
+ for (const name of Object.keys(zip.files).filter((n) => !zip.files[n].dir && re.test(n)))
76
+ items.push({ name: name.split("/").pop(), data: await zip.file(name).async("nodebuffer") })
77
+ } else throw new Error(`extract_images supports hwp, hwpx, docx, pptx, xlsx, zip (got .${ext})`)
78
+ const written = []
79
+ for (const it of items) {
80
+ const out = path.join(dir, it.name.replace(/[^\w.-]+/g, "_"))
81
+ fs.writeFileSync(out, it.data)
82
+ written.push({ path: out, bytes: it.data.length })
83
+ }
84
+ return { dir, images: written }
85
+ }
package/src/index.js ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Palette Docs: documents and images for Palette Code. `palette-docs mcp` serves the tools over stdio; the other
3
+ // subcommands do the same work from a shell.
4
+ import fs from "node:fs"
5
+ import path from "node:path"
6
+ import { readDocument, readDocuments, formatsTable, MAX_ARCHIVE_FILES } from "./read.js"
7
+ import { normalizeImage, extractImages } from "./image.js"
8
+ import { createDocument, convertDocument, NATIVE, VIA_LO, DEFAULT_FONT } from "./write.js"
9
+
10
+ const VERSION = "0.1.0"
11
+
12
+ async function mcp() {
13
+ const { McpServer } = await import("@modelcontextprotocol/sdk/server/mcp.js")
14
+ const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js")
15
+ const { z } = await import("zod")
16
+ const server = new McpServer({ name: "palette-docs", version: VERSION })
17
+ const text = (t) => ({ content: [{ type: "text", text: t }] })
18
+ const fail = (e) => ({ content: [{ type: "text", text: `error: ${e.message}` }], isError: true })
19
+ const header = (r) => `# ${path.basename(r.file)} (${r.format}, ${r.bytes} B, ${r.chars} chars${r.truncated ? ", truncated" : ""}${Object.entries(r.meta || {}).filter(([, v]) => v != null && typeof v !== "object").map(([k, v]) => `, ${k}=${v}`).join("")})\n\n`
20
+
21
+ server.registerTool("read_document", {
22
+ title: "Read a document",
23
+ description: `Read one file as markdown text: ${["hwp", "hwpx", "docx", "doc", "rtf", "odt", "xlsx", "xls", "csv", "pptx", "ppt", "pdf", "html", "txt", "md", "json"].join(", ")} and zip (expands up to ${MAX_ARCHIVE_FILES} files). Tables come back as markdown tables; sheets and slides are headed. For images use read_image.`,
24
+ inputSchema: { path: z.string().describe("absolute or relative file path"), max_chars: z.number().int().positive().optional().describe("default 60000") },
25
+ }, async ({ path: p, max_chars }) => {
26
+ try { const r = await readDocument(p, { maxChars: max_chars }); return text(header(r) + r.text) } catch (e) { return fail(e) }
27
+ })
28
+
29
+ server.registerTool("read_documents", {
30
+ title: "Read several documents",
31
+ description: `Read up to ${MAX_ARCHIVE_FILES} files in one call (same formats as read_document); each comes back under its own heading.`,
32
+ inputSchema: { paths: z.array(z.string()).min(1).max(MAX_ARCHIVE_FILES), max_chars_each: z.number().int().positive().optional().describe("default 20000") },
33
+ }, async ({ paths, max_chars_each }) => {
34
+ const rs = await readDocuments(paths, { maxChars: max_chars_each || 20_000 })
35
+ return text(rs.map((r) => (r.error ? `# ${path.basename(r.file)}\n\nerror: ${r.error}\n` : header(r) + r.text + "\n")).join("\n---\n\n"))
36
+ })
37
+
38
+ server.registerTool("read_image", {
39
+ title: "Look at an image",
40
+ description: "Return an image for the model to see (png, jpg, webp, gif, bmp, tiff, heic, svg, avif). It is EXIF-rotated and resized to fit max_side (default 1568) and sent as PNG/JPEG. Use a vision seat (palette-llm-max or palette-llm-fast).",
41
+ inputSchema: { path: z.string(), max_side: z.number().int().min(256).max(4096).optional() },
42
+ }, async ({ path: p, max_side }) => {
43
+ try {
44
+ const r = await normalizeImage(p, { maxSide: max_side })
45
+ return { content: [{ type: "text", text: `${path.basename(p)}: ${r.original.format} ${r.original.width}×${r.original.height} → ${r.width}×${r.height} ${r.mimeType} (${r.data.length} B, decoded via ${r.decodedVia})` }, { type: "image", data: r.data.toString("base64"), mimeType: r.mimeType }] }
46
+ } catch (e) { return fail(e) }
47
+ })
48
+
49
+ server.registerTool("extract_images", {
50
+ title: "Extract embedded images",
51
+ description: "Write the images embedded in a hwp, hwpx, docx, pptx, xlsx or zip to a folder and list them, so they can be viewed with read_image or reused in a new document.",
52
+ inputSchema: { path: z.string(), out_dir: z.string().optional().describe("default: a temp folder") },
53
+ }, async ({ path: p, out_dir }) => {
54
+ try { const r = await extractImages(p, out_dir); return text(`${r.images.length} images in ${r.dir}\n` + r.images.map((i) => `- ${i.path} (${i.bytes} B)`).join("\n")) } catch (e) { return fail(e) }
55
+ })
56
+
57
+ server.registerTool("create_document", {
58
+ title: "Create a document",
59
+ description: `Write a real file from markdown (headings, paragraphs, bullets, numbered lists, tables, code). Native: ${NATIVE.join(", ")}. Via LibreOffice when installed: ${Object.keys(VIA_LO).join(", ")}. hwp (binary) is not available: create hwpx, which 한글 opens natively. Korean font defaults to ${DEFAULT_FONT}; HWPX keeps Hancom's default fonts.`,
60
+ inputSchema: { path: z.string().describe("output file path; the extension picks the format unless format is given"), markdown: z.string(), title: z.string().optional(), format: z.string().optional(), font: z.string().optional().describe("font family for docx/xlsx/pptx/html, e.g. Noto Sans KR") },
61
+ }, async ({ path: p, markdown, title, format, font }) => {
62
+ try { const r = await createDocument({ format, out: p, title: title || path.basename(p, path.extname(p)), markdown, font: font || DEFAULT_FONT }); return text(`created ${r.path} (${r.format}, ${r.blocks} blocks${r.via ? `, via ${r.via}` : ""})`) } catch (e) { return fail(e) }
63
+ })
64
+
65
+ server.registerTool("convert_document", {
66
+ title: "Convert a document",
67
+ description: "Convert a file to another format (e.g. hwpx → docx, docx → pdf, xlsx → csv, pptx → pdf). LibreOffice is used when it is installed and the pair needs it; otherwise the file is read and rewritten natively.",
68
+ inputSchema: { path: z.string(), to: z.string().describe("target extension"), out_dir: z.string().optional() },
69
+ }, async ({ path: p, to, out_dir }) => {
70
+ try { const r = await convertDocument(p, to, out_dir); return text(`converted → ${r.path} (via ${r.via})`) } catch (e) { return fail(e) }
71
+ })
72
+
73
+ server.registerTool("list_formats", { title: "Supported formats", description: "What can be read and created on this machine.", inputSchema: {} }, async () => text(JSON.stringify(formatsTable(), null, 2)))
74
+
75
+ await server.connect(new StdioServerTransport())
76
+ }
77
+
78
+ async function cli(argv) {
79
+ const [cmd, ...rest] = argv
80
+ const flag = (name, dflt) => { const i = rest.indexOf(`--${name}`); return i >= 0 ? rest[i + 1] : dflt }
81
+ const positional = rest.filter((a, i) => !a.startsWith("--") && (i === 0 || !rest[i - 1].startsWith("--")))
82
+ switch (cmd) {
83
+ case "mcp": return mcp()
84
+ case "read": { const r = await readDocument(positional[0], { maxChars: +flag("max-chars", 5_000_000) }); process.stdout.write(r.text + "\n"); return }
85
+ case "create": {
86
+ const out = flag("out") || positional[0]
87
+ const md = flag("markdown") || (positional[1] && fs.existsSync(positional[1]) ? fs.readFileSync(positional[1], "utf8") : fs.readFileSync(0, "utf8"))
88
+ const r = await createDocument({ format: flag("format"), out, title: flag("title", path.basename(out, path.extname(out))), markdown: md, font: flag("font", DEFAULT_FONT) })
89
+ console.log(`created ${r.path}`); return
90
+ }
91
+ case "convert": { const r = await convertDocument(positional[0], positional[1], flag("out-dir")); console.log(`converted → ${r.path} (via ${r.via})`); return }
92
+ case "image": { const r = await normalizeImage(positional[0], { maxSide: +flag("max-side", 1568) }); const out = flag("out") || positional[0].replace(/\.[^.]+$/, "") + (r.mimeType === "image/png" ? ".normalized.png" : ".normalized.jpg"); fs.writeFileSync(out, r.data); console.log(`${out} ${r.width}×${r.height}`); return }
93
+ case "extract-images": { const r = await extractImages(positional[0], flag("out-dir")); console.log(r.images.map((i) => i.path).join("\n") || "(no images)"); return }
94
+ case "formats": console.log(JSON.stringify(formatsTable(), null, 2)); return
95
+ case "version": case "--version": case "-v": console.log(VERSION); return
96
+ default:
97
+ console.log(`palette-docs ${VERSION} — documents and images for Palette Code
98
+
99
+ palette-docs mcp stdio MCP server (read_document, read_documents, read_image,
100
+ extract_images, create_document, convert_document, list_formats)
101
+ palette-docs read <file> [--max-chars N] hwp hwpx docx doc rtf odt xlsx xls csv pptx ppt pdf html txt md json zip
102
+ palette-docs create <out> [<markdown.md>] [--title T] [--font F] [--format docx|hwpx|xlsx|pptx|md|csv|html|pdf|doc|xls|ppt|odt]
103
+ palette-docs convert <file> <to> [--out-dir D]
104
+ palette-docs image <file> [--max-side N] [--out F] png jpg webp gif bmp tiff heic svg avif → model-ready png/jpg
105
+ palette-docs extract-images <file> [--out-dir D]
106
+ palette-docs formats`)
107
+ }
108
+ }
109
+
110
+ cli(process.argv.slice(2)).catch((e) => { console.error(e.message); process.exit(1) })
package/src/read.js ADDED
@@ -0,0 +1,214 @@
1
+ // Read any office / Korean / PDF / archive input into markdown text the agent can work with.
2
+ import fs from "node:fs"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { execFileSync } from "node:child_process"
6
+ import JSZip from "jszip"
7
+ import { XMLParser } from "fast-xml-parser"
8
+ import TurndownService from "turndown"
9
+ import { gfm } from "turndown-plugin-gfm"
10
+ import * as XLSX from "xlsx"
11
+ import { hwpRead, hwpxRead } from "./hwp.js"
12
+ import { mdTable } from "./blocks.js"
13
+
14
+ export const DOC_EXT = ["hwp", "hwpx", "docx", "doc", "rtf", "odt", "xlsx", "xlsm", "xls", "ods", "csv", "tsv", "pptx", "ppt", "odp", "pdf", "txt", "md", "markdown", "json", "html", "htm", "xml", "yaml", "yml"]
15
+ export const IMG_EXT = ["png", "jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff", "heic", "heif", "svg", "avif"]
16
+ export const ARCHIVE_EXT = ["zip"]
17
+ export const MAX_ARCHIVE_FILES = 20
18
+
19
+ export const extOf = (p) => path.extname(p).slice(1).toLowerCase()
20
+
21
+ // ---------- LibreOffice (optional) ----------
22
+ export function soffice() {
23
+ const candidates = [process.env.PALETTE_DOCS_SOFFICE, "/Applications/LibreOffice.app/Contents/MacOS/soffice", "/opt/homebrew/bin/soffice", "/usr/bin/soffice", "/usr/bin/libreoffice", "C:\\Program Files\\LibreOffice\\program\\soffice.exe"].filter(Boolean)
24
+ for (const c of candidates) if (fs.existsSync(c)) return c
25
+ for (const name of ["soffice", "libreoffice"]) {
26
+ try {
27
+ const found = execFileSync(process.platform === "win32" ? "where" : "which", [name], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim().split("\n")[0]
28
+ if (found) return found
29
+ } catch {}
30
+ }
31
+ return null
32
+ }
33
+
34
+ /** Convert with LibreOffice into `outDir`; returns the produced path. Separate profile so an open LibreOffice does not block it. */
35
+ export function sofficeConvert(src, toExt, outDir) {
36
+ const bin = soffice()
37
+ if (!bin) throw new Error(`LibreOffice is needed to handle .${extOf(src)} -> .${toExt} (install from libreoffice.org or brew install --cask libreoffice)`)
38
+ fs.mkdirSync(outDir, { recursive: true })
39
+ const profile = path.join(os.tmpdir(), "palette-docs-lo")
40
+ execFileSync(bin, [`-env:UserInstallation=file://${profile}`, "--headless", "--norestore", "--convert-to", toExt, "--outdir", outDir, src], { stdio: ["ignore", "pipe", "pipe"], timeout: 180_000 })
41
+ const out = path.join(outDir, path.basename(src, path.extname(src)) + "." + toExt.split(":")[0])
42
+ if (!fs.existsSync(out)) throw new Error(`LibreOffice did not produce ${out}`)
43
+ return out
44
+ }
45
+
46
+ const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), "palette-docs-"))
47
+
48
+ // ---------- DOCX ----------
49
+ async function readDocx(buffer) {
50
+ const mammoth = (await import("mammoth")).default
51
+ const { value: html, messages } = await mammoth.convertToHtml({ buffer })
52
+ const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced", bulletListMarker: "-" })
53
+ td.use(gfm)
54
+ const text = td.turndown(html)
55
+ return { text, meta: { warnings: messages.filter((m) => m.type === "warning").length } }
56
+ }
57
+
58
+ // ---------- XLSX / CSV ----------
59
+ function readSheet(buffer, { maxRows }, ext = "") {
60
+ // CSV/TSV are text: decode as UTF-8 (BOM tolerated) so Korean survives; binary workbooks go in as buffers.
61
+ const wb = ["csv", "tsv", "txt"].includes(ext)
62
+ ? XLSX.read(buffer.toString("utf8").replace(/^/, ""), { type: "string", cellDates: true, FS: ext === "tsv" ? "\t" : undefined })
63
+ : XLSX.read(buffer, { type: "buffer", cellDates: true })
64
+ const parts = []
65
+ const meta = { sheets: [] }
66
+ for (const name of wb.SheetNames) {
67
+ const ws = wb.Sheets[name]
68
+ const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: false, defval: "" })
69
+ const trimmed = rows.filter((r) => r.some((c) => String(c).trim() !== ""))
70
+ meta.sheets.push({ name, rows: trimmed.length, truncated: trimmed.length > maxRows })
71
+ parts.push(`## ${name} (${trimmed.length} rows)`)
72
+ if (trimmed.length) parts.push(mdTable(trimmed.slice(0, maxRows)))
73
+ if (trimmed.length > maxRows) parts.push(`… ${trimmed.length - maxRows} more rows not shown`)
74
+ parts.push("")
75
+ }
76
+ return { text: parts.join("\n"), meta }
77
+ }
78
+
79
+ // ---------- PPTX ----------
80
+ const ooxml = new XMLParser({ preserveOrder: true, ignoreAttributes: true, trimValues: false, parseTagValue: false })
81
+ const tagOf = (n) => Object.keys(n).find((k) => k !== ":@")
82
+ function collect(node, tag, out = []) {
83
+ const t = tagOf(node)
84
+ if (t === tag) out.push(node)
85
+ else if (Array.isArray(node[t])) for (const c of node[t]) collect(c, tag, out)
86
+ return out
87
+ }
88
+ const textUnder = (node) => {
89
+ const t = tagOf(node)
90
+ if (t === "#text") return String(node["#text"])
91
+ return Array.isArray(node[t]) ? node[t].map(textUnder).join("") : ""
92
+ }
93
+ async function readPptx(buffer) {
94
+ const zip = await JSZip.loadAsync(buffer)
95
+ const slides = Object.keys(zip.files).filter((f) => /^ppt\/slides\/slide\d+\.xml$/.test(f)).sort((a, b) => +a.match(/(\d+)\.xml/)[1] - +b.match(/(\d+)\.xml/)[1])
96
+ const parts = []
97
+ for (const f of slides) {
98
+ const n = f.match(/(\d+)\.xml/)[1]
99
+ const tree = { root: ooxml.parse(await zip.file(f).async("string")) }
100
+ const lines = []
101
+ for (const sp of collect(tree, "p:sp")) {
102
+ const paras = collect(sp, "a:p").map((p) => collect(p, "a:t").map(textUnder).join("")).filter((s) => s.trim())
103
+ if (paras.length) lines.push(...paras)
104
+ }
105
+ for (const tbl of collect(tree, "a:tbl")) {
106
+ const rows = collect(tbl, "a:tr").map((tr) => collect(tr, "a:tc").map((tc) => collect(tc, "a:p").map((p) => collect(p, "a:t").map(textUnder).join("")).join(" ")))
107
+ if (rows.length) lines.push("", mdTable(rows), "")
108
+ }
109
+ const notes = zip.file(`ppt/notesSlides/notesSlide${n}.xml`)
110
+ if (notes) {
111
+ const nt = collect({ root: ooxml.parse(await notes.async("string")) }, "a:t").map(textUnder).join("").trim()
112
+ if (nt) lines.push("", `> notes: ${nt}`)
113
+ }
114
+ parts.push(`## Slide ${n}`, ...lines, "")
115
+ }
116
+ return { text: parts.join("\n"), meta: { slides: slides.length } }
117
+ }
118
+
119
+ // ---------- PDF ----------
120
+ async function readPdf(buffer) {
121
+ const { PDFParse } = await import("pdf-parse")
122
+ const p = new PDFParse({ data: new Uint8Array(buffer) })
123
+ try {
124
+ const r = await p.getText()
125
+ return { text: String(r.text || ""), meta: { pages: r.total ?? r.numpages ?? undefined } }
126
+ } finally {
127
+ try { await p.destroy() } catch {}
128
+ }
129
+ }
130
+
131
+ // ---------- HTML ----------
132
+ function readHtml(buffer) {
133
+ const td = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" })
134
+ td.use(gfm)
135
+ td.remove(["script", "style", "noscript"])
136
+ return { text: td.turndown(buffer.toString("utf8")), meta: {} }
137
+ }
138
+
139
+ // ---------- ZIP ----------
140
+ async function readZip(file, opts, depth) {
141
+ const zip = await JSZip.loadAsync(fs.readFileSync(file))
142
+ const entries = Object.values(zip.files).filter((e) => !e.dir && !/(^|\/)(__MACOSX|\.)/.test(e.name))
143
+ const supported = entries.filter((e) => [...DOC_EXT, ...IMG_EXT].includes(extOf(e.name)))
144
+ const picked = supported.slice(0, MAX_ARCHIVE_FILES)
145
+ const dir = tmpDir()
146
+ const parts = [`# ${path.basename(file)} — ${entries.length} files, ${supported.length} readable, ${picked.length} read`]
147
+ const list = entries.map((e) => `- ${e.name} (${e._data?.uncompressedSize ?? "?"} B)`)
148
+ parts.push(...list.slice(0, 200), "")
149
+ const per = Math.max(2000, Math.floor(opts.maxChars / Math.max(1, picked.length)))
150
+ for (const e of picked) {
151
+ const out = path.join(dir, e.name.replace(/[\\/]/g, "__"))
152
+ fs.writeFileSync(out, await e.async("nodebuffer"))
153
+ try {
154
+ const r = await readDocument(out, { ...opts, maxChars: per }, depth + 1)
155
+ parts.push(`## ${e.name}`, r.text, "")
156
+ } catch (err) {
157
+ parts.push(`## ${e.name}`, `(could not read: ${err.message})`, "")
158
+ }
159
+ }
160
+ if (supported.length > picked.length) parts.push(`… ${supported.length - picked.length} more readable files not read (limit ${MAX_ARCHIVE_FILES})`)
161
+ return { text: parts.join("\n"), meta: { files: entries.length, read: picked.length, extracted_to: dir } }
162
+ }
163
+
164
+ // ---------- dispatcher ----------
165
+ export async function readDocument(file, opts = {}, depth = 0) {
166
+ const o = { maxChars: 60_000, maxRows: 300, ...opts }
167
+ if (!fs.existsSync(file)) throw new Error(`no such file: ${file}`)
168
+ const ext = extOf(file)
169
+ const stat = fs.statSync(file)
170
+ let result
171
+ if (ext === "zip") {
172
+ if (depth > 0) throw new Error("nested archives are not expanded")
173
+ result = await readZip(file, o, depth)
174
+ } else if (IMG_EXT.includes(ext)) {
175
+ const sharp = (await import("sharp")).default
176
+ let m = {}
177
+ try { m = await sharp(file).metadata() } catch {}
178
+ result = { text: `(image ${ext}${m.width ? ` ${m.width}×${m.height}` : ""}; use read_image to see it)`, meta: { width: m.width, height: m.height } }
179
+ } else {
180
+ const buffer = fs.readFileSync(file)
181
+ switch (ext) {
182
+ case "hwp": result = hwpRead(buffer); result = { text: result.text, meta: { sections: result.sections, images: result.images.length } }; break
183
+ case "hwpx": result = await hwpxRead(buffer); result = { text: result.text, meta: { sections: result.sections, images: result.images.length, title: result.title } }; break
184
+ case "docx": result = await readDocx(buffer); break
185
+ case "doc": case "rtf": case "odt": case "wps": result = await readDocx(fs.readFileSync(sofficeConvert(file, "docx", tmpDir()))); break
186
+ case "xlsx": case "xlsm": case "xls": case "ods": case "csv": case "tsv": result = readSheet(buffer, o, ext); break
187
+ case "pptx": result = await readPptx(buffer); break
188
+ case "ppt": case "odp": result = await readPptx(fs.readFileSync(sofficeConvert(file, "pptx", tmpDir()))); break
189
+ case "pdf": result = await readPdf(buffer); break
190
+ case "html": case "htm": result = readHtml(buffer); break
191
+ case "txt": case "md": case "markdown": case "json": case "xml": case "yaml": case "yml": result = { text: buffer.toString("utf8"), meta: {} }; break
192
+ default: throw new Error(`unsupported input .${ext}; supported: ${[...DOC_EXT, ...IMG_EXT, ...ARCHIVE_EXT].join(", ")}`)
193
+ }
194
+ }
195
+ let text = result.text.replace(/\r\n/g, "\n").replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim()
196
+ const chars = text.length
197
+ let truncated = false
198
+ if (chars > o.maxChars) { text = text.slice(0, o.maxChars) + `\n\n… truncated (${chars} chars total; raise max_chars to read more)`; truncated = true }
199
+ return { file, format: ext, bytes: stat.size, chars, truncated, meta: result.meta || {}, text }
200
+ }
201
+
202
+ export async function readDocuments(files, opts = {}) {
203
+ if (files.length > MAX_ARCHIVE_FILES) throw new Error(`at most ${MAX_ARCHIVE_FILES} files per call (got ${files.length})`)
204
+ const out = []
205
+ for (const f of files) {
206
+ try { out.push(await readDocument(f, opts)) } catch (e) { out.push({ file: f, error: e.message }) }
207
+ }
208
+ return out
209
+ }
210
+
211
+ export const formatsTable = () => ({
212
+ read: { documents: DOC_EXT, images: IMG_EXT, archives: ARCHIVE_EXT, max_files_per_call: MAX_ARCHIVE_FILES, libreoffice: !!soffice() },
213
+ create: { native: ["docx", "hwpx", "xlsx", "pptx", "md", "txt", "csv", "html"], via_libreoffice: ["pdf", "doc", "xls", "ppt", "odt", "ods", "odp", "rtf"], not_available: ["hwp (binary 5.0 export needs 한글 or a LibreOffice H2Orestart build; create hwpx instead)"] },
214
+ })
package/src/write.js ADDED
@@ -0,0 +1,320 @@
1
+ // Create real office files from markdown: DOCX, HWPX (한글), XLSX, PPTX natively; PDF/DOC/XLS/PPT/ODF via LibreOffice.
2
+ // DOCX/HWPX/XLSX writers ported from Palette Island ui/export.js (same owner). HWPX starts from Hancom's own blank
3
+ // template (vendor/hwpx/blank.hwpx, from hwpxlib, Apache-2.0) so 한글 opens it as a native document.
4
+ import fs from "node:fs"
5
+ import os from "node:os"
6
+ import path from "node:path"
7
+ import { fileURLToPath } from "node:url"
8
+ import JSZip from "jszip"
9
+ import { blocksOf, esc, plain, today, numCols, marker, toMarkdown } from "./blocks.js"
10
+ import { sofficeConvert } from "./read.js"
11
+
12
+ const here = path.dirname(fileURLToPath(import.meta.url))
13
+ export const DEFAULT_FONT = "Malgun Gothic" // 맑은 고딕: the Korean office default; Word/Excel substitute on Mac
14
+ const FONT_KO = { "Malgun Gothic": "맑은 고딕", "Noto Sans KR": "Noto Sans KR", "Apple SD Gothic Neo": "Apple SD Gothic Neo", "Nanum Gothic": "나눔고딕", "Palette Noto Sans KR": "Palette Noto Sans KR" }
15
+
16
+ // ---------- DOCX ----------
17
+ export async function toDocx(blocks, title, { font = DEFAULT_FONT } = {}) {
18
+ const zip = new JSZip()
19
+ const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'
20
+ const run = (t, { b = false, color = "", sz = 0, mono = false } = {}) => `<w:r><w:rPr>${mono ? '<w:rFonts w:ascii="Menlo" w:hAnsi="Menlo" w:cs="Menlo"/>' : ""}${b ? "<w:b/>" : ""}${color ? `<w:color w:val="${color}"/>` : ""}${sz ? `<w:sz w:val="${sz}"/>` : ""}</w:rPr><w:t xml:space="preserve">${esc(t)}</w:t></w:r>`
21
+ const para = (t, style = "", extra = "", ro = {}) => `<w:p><w:pPr>${style ? `<w:pStyle w:val="${style}"/>` : ""}${extra}</w:pPr>${run(t, ro)}</w:p>`
22
+ const docTitle = (blocks.find((k) => k.type === "h" && k.level === 1) || {}).text || title
23
+ const body = [para(docTitle, "Title"), para(`${today()} · Palette`, "Subtitle")]
24
+ for (const k of blocks) {
25
+ if (k.type === "h" && k.level === 1 && k.text === docTitle) continue
26
+ if (k.type === "h") body.push(para(k.text, k.level <= 2 ? "Heading1" : "Heading2"))
27
+ else if (k.type === "li") body.push(para(marker(k) + "\t" + k.text, "ListBullet"))
28
+ else if (k.type === "p") body.push(para(k.text))
29
+ else if (k.type === "code") for (const line of k.text.split("\n")) body.push(para(line, "Code", "", { mono: true, sz: 18 }))
30
+ else if (k.type === "table") {
31
+ const cols = Math.max(...k.rows.map((r) => r.length))
32
+ const nc = numCols(k.rows)
33
+ const TW = 9506 // A4 text width in twips (11906 minus 1200 margins either side)
34
+ const len = Array.from({ length: cols }, (_, c) => Math.min(30, Math.max(4, ...k.rows.map((r) => [...String(r[c] ?? "")].reduce((a, ch) => a + (/[가-힣]/.test(ch) ? 2 : 1), 0)))))
35
+ const tot = len.reduce((a, b) => a + b, 0)
36
+ const gw = len.map((x) => Math.floor((TW * x) / tot))
37
+ gw[cols - 1] += TW - gw.reduce((a, b) => a + b, 0)
38
+ const line = (x) => `<w:${x} w:val="single" w:sz="4" w:color="D9D4CC"/>`
39
+ const bd = `<w:tblBorders>${line("top")}<w:left w:val="nil"/>${line("bottom")}<w:right w:val="nil"/>${line("insideH")}<w:insideV w:val="nil"/></w:tblBorders>`
40
+ body.push(
41
+ `<w:tbl><w:tblPr><w:tblW w:w="${TW}" w:type="dxa"/>${bd}<w:tblLayout w:type="fixed"/><w:tblCellMar><w:top w:w="80" w:type="dxa"/><w:left w:w="120" w:type="dxa"/><w:bottom w:w="80" w:type="dxa"/><w:right w:w="120" w:type="dxa"/></w:tblCellMar></w:tblPr><w:tblGrid>${gw.map((x) => `<w:gridCol w:w="${x}"/>`).join("")}</w:tblGrid>` +
42
+ k.rows
43
+ .map(
44
+ (r, ri) =>
45
+ `<w:tr>${ri === 0 ? "<w:trPr><w:tblHeader/></w:trPr>" : ""}` +
46
+ Array.from({ length: cols }, (_, ci) => r[ci] ?? "")
47
+ .map((c, ci) => {
48
+ const fill = ri === 0 ? "1A1A1A" : ri % 2 === 0 ? "F5F3EF" : "FFFFFF"
49
+ const jc = nc[ci] ? '<w:jc w:val="right"/>' : ""
50
+ return `<w:tc><w:tcPr><w:tcW w:w="${gw[ci]}" w:type="dxa"/><w:shd w:val="clear" w:color="auto" w:fill="${fill}"/><w:vAlign w:val="center"/></w:tcPr><w:p><w:pPr><w:spacing w:before="0" w:after="0"/>${jc}</w:pPr>${run(c, { b: ri === 0, color: ri === 0 ? "FFFFFF" : "", sz: 19 })}</w:p></w:tc>`
51
+ })
52
+ .join("") +
53
+ "</w:tr>",
54
+ )
55
+ .join("") +
56
+ "</w:tbl>",
57
+ )
58
+ body.push('<w:p><w:pPr><w:spacing w:after="120"/></w:pPr></w:p>')
59
+ }
60
+ }
61
+ const ea = FONT_KO[font] || font
62
+ const styles = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles ${W}>
63
+ <w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="${esc(font)}" w:hAnsi="${esc(font)}" w:eastAsia="${esc(ea)}" w:cs="${esc(font)}"/><w:sz w:val="21"/><w:color w:val="1A1A1A"/><w:lang w:eastAsia="ko-KR"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="300" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
64
+ <w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>
65
+ <w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:pPr><w:spacing w:after="60"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/><w:color w:val="000000"/></w:rPr></w:style>
66
+ <w:style w:type="paragraph" w:styleId="Subtitle"><w:name w:val="Subtitle"/><w:basedOn w:val="Normal"/><w:pPr><w:pBdr><w:bottom w:val="single" w:sz="12" w:space="8" w:color="000000"/></w:pBdr><w:spacing w:after="360"/></w:pPr><w:rPr><w:sz w:val="18"/><w:color w:val="8A8A8A"/></w:rPr></w:style>
67
+ <w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="320" w:after="120"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="30"/><w:color w:val="000000"/></w:rPr></w:style>
68
+ <w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:next w:val="Normal"/><w:pPr><w:keepNext/><w:spacing w:before="220" w:after="80"/><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="24"/><w:color w:val="333333"/></w:rPr></w:style>
69
+ <w:style w:type="paragraph" w:styleId="ListBullet"><w:name w:val="List Bullet"/><w:basedOn w:val="Normal"/><w:pPr><w:tabs><w:tab w:val="left" w:pos="360"/></w:tabs><w:spacing w:after="60"/><w:ind w:left="360" w:hanging="360"/></w:pPr></w:style>
70
+ <w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Code"/><w:basedOn w:val="Normal"/><w:pPr><w:shd w:val="clear" w:color="auto" w:fill="F5F3EF"/><w:spacing w:after="0" w:line="260" w:lineRule="auto"/></w:pPr></w:style>
71
+ <w:style w:type="paragraph" w:styleId="Footer"><w:name w:val="footer"/><w:basedOn w:val="Normal"/><w:rPr><w:sz w:val="16"/><w:color w:val="8A8A8A"/></w:rPr></w:style>
72
+ </w:styles>`
73
+ const footer = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:ftr ${W}><w:p><w:pPr><w:pStyle w:val="Footer"/><w:jc w:val="right"/></w:pPr>${run(docTitle.slice(0, 60) + " · Palette · ")}<w:fldSimple w:instr="PAGE"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:ftr>`
74
+ zip.file("[Content_Types].xml", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/><Override PartName="/word/footer1.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"/></Types>')
75
+ zip.file("_rels/.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>')
76
+ zip.file("word/_rels/document.xml.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/><Relationship Id="rIdF" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer" Target="footer1.xml"/></Relationships>')
77
+ zip.file("word/styles.xml", styles)
78
+ zip.file("word/footer1.xml", footer)
79
+ zip.file("word/document.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document ${W}><w:body>${body.join("")}<w:sectPr><w:footerReference w:type="default" r:id="rIdF"/><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1300" w:right="1200" w:bottom="1300" w:left="1200" w:header="700" w:footer="600" w:gutter="0"/></w:sectPr></w:body></w:document>`)
80
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" })
81
+ }
82
+
83
+ // ---------- HWPX ----------
84
+ export async function toHwpx(blocks, title) {
85
+ const zip = await JSZip.loadAsync(fs.readFileSync(path.join(here, "..", "vendor", "hwpx", "blank.hwpx")))
86
+ const secPath = "Contents/section0.xml"
87
+ const sec = await zip.file(secPath).async("string")
88
+ zip.file("Contents/header.xml", hwpxHeader(await zip.file("Contents/header.xml").async("string")))
89
+ // Hancom blank + hwpxHeader: charPr 0 = 10pt body, 7 = 10pt bold, 8 = 16pt bold (title), 9 = 13pt bold, 10 = 11pt bold;
90
+ // paraPr 3 = justified body, 16/17/18 = left/right/centre (table cells), 19 = heading (space before/after)
91
+ let id = 1000
92
+ const run = (t, char) => `<hp:run charPrIDRef="${char}"><hp:t>${esc(t)}</hp:t></hp:run>`
93
+ const p = (t, { char = 0, para = 3 } = {}) => `<hp:p id="${id++}" paraPrIDRef="${para}" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0">${run(t, char)}</hp:p>`
94
+ const paras = [p(title, { char: 8, para: 19 })]
95
+ for (const k of blocks) {
96
+ if (k.type === "h") paras.push(p(k.text, { char: k.level <= 1 ? 8 : k.level === 2 ? 9 : 10, para: 19 }))
97
+ else if (k.type === "li") paras.push(p(marker(k) + " " + k.text))
98
+ else if (k.type === "p") paras.push(p(k.text))
99
+ else if (k.type === "code") for (const line of k.text.split("\n")) paras.push(p(line))
100
+ else if (k.type === "table") { paras.push(hwpxTable(k.rows, () => id++)); paras.push(p("")) }
101
+ }
102
+ const out = sec.replace(/<\/hs:sec>\s*$/, paras.join("") + "</hs:sec>")
103
+ zip.file(secPath, out)
104
+ const hpf = await zip.file("Contents/content.hpf").async("string")
105
+ zip.file("Contents/content.hpf", hpf.replace(/<opf:title>[^<]*<\/opf:title>|<opf:title\/>/, `<opf:title>${esc(title)}</opf:title>`))
106
+ // mimetype must stay first and uncompressed (OCF rule) — JSZip keeps insertion order of the loaded archive
107
+ zip.file("mimetype", "application/hwp+zip", { compression: "STORE" })
108
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE", mimeType: "application/hwp+zip" })
109
+ }
110
+
111
+ /** Adds what tables and headings need to Hancom's blank header, in Hancom's own markup (copied from a 한글-saved
112
+ * table, hwpxlib testFile/reader_writer/SimpleTable.hwpx): borderFill 3 = 0.12 mm solid grid, 4 = the same with a
113
+ * shaded header fill; charPr 7-10 = bold sizes; paraPr 16-19 = cell left/right/centre and heading spacing. */
114
+ function hwpxHeader(h) {
115
+ const bump = (tag, n) => { h = h.replace(new RegExp(`<hh:${tag} itemCnt="(\\d+)"`), (m, c) => `<hh:${tag} itemCnt="${+c + n}"`) }
116
+ const side = (k) => `<hh:${k}Border type="SOLID" width="0.12 mm" color="#000000"/>`
117
+ const bf = (bid, fill) => `<hh:borderFill id="${bid}" threeD="0" shadow="0" centerLine="NONE" breakCellSeparateLine="0"><hh:slash type="NONE" Crooked="0" isCounter="0"/><hh:backSlash type="NONE" Crooked="0" isCounter="0"/>${["left", "right", "top", "bottom"].map(side).join("")}<hh:diagonal type="SOLID" width="0.1 mm" color="#000000"/>${fill ? `<hc:fillBrush><hc:winBrush faceColor="${fill}" hatchColor="#FF000000" alpha="0"/></hc:fillBrush>` : ""}</hh:borderFill>`
118
+ h = h.replace("</hh:borderFills>", bf(3) + bf(4, "#E8EEF6") + "</hh:borderFills>"); bump("borderFills", 2)
119
+ const c0 = h.match(/<hh:charPr id="0"[\s\S]*?<\/hh:charPr>/)[0]
120
+ const bold = (cid, ht) => c0.replace('id="0"', `id="${cid}"`).replace(/height="\d+"/, `height="${ht}"`).replace("<hh:underline", "<hh:bold/><hh:underline")
121
+ h = h.replace("</hh:charProperties>", bold(7, 1000) + bold(8, 1600) + bold(9, 1300) + bold(10, 1100) + "</hh:charProperties>"); bump("charProperties", 4)
122
+ const p3 = h.match(/<hh:paraPr id="3"[\s\S]*?<\/hh:paraPr>/)[0]
123
+ const para = (pid, align, prev = 0, next = 0) => p3.replace('id="3"', `id="${pid}"`).replace(/horizontal="\w+"/, `horizontal="${align}"`)
124
+ .replace(/<hc:prev value="0"/g, `<hc:prev value="${prev}"`).replace(/<hc:next value="0"/g, `<hc:next value="${next}"`)
125
+ .replace(/lineSpacing type="PERCENT" value="160"/g, `lineSpacing type="PERCENT" value="${pid === 19 ? 160 : 130}"`)
126
+ h = h.replace("</hh:paraProperties>", para(16, "LEFT") + para(17, "RIGHT") + para(18, "CENTER") + para(19, "LEFT", 1200, 400) + "</hh:paraProperties>"); bump("paraProperties", 4)
127
+ return h
128
+ }
129
+
130
+ /** A real 한글 table: full text width, header row shaded, bold and repeated on every page, numeric columns right-aligned. */
131
+ function hwpxTable(rows, nextId) {
132
+ const W = 42520 // A4 text width in HWPUNIT (59528 minus 8504 margins either side)
133
+ const n = Math.max(...rows.map((r) => r.length))
134
+ const body = rows.map((r) => Array.from({ length: n }, (_, i) => r[i] ?? ""))
135
+ const nc = numCols(body)
136
+ const len = Array.from({ length: n }, (_, c) => Math.min(30, Math.max(4, ...body.map((r) => [...String(r[c])].reduce((a, ch) => a + (/[가-힣]/.test(ch) ? 2 : 1), 0)))))
137
+ const tot = len.reduce((a, b) => a + b, 0)
138
+ const w = len.map((x) => Math.floor((W * x) / tot))
139
+ w[n - 1] += W - w.reduce((a, b) => a + b, 0)
140
+ const H = 2000
141
+ const cell = (txt, r, c) => {
142
+ const head = r === 0
143
+ const para = head ? 18 : nc[c] ? 17 : 16
144
+ return `<hp:tc name="" header="${head ? 1 : 0}" hasMargin="0" protect="0" editable="0" dirty="0" borderFillIDRef="${head ? 4 : 3}"><hp:subList id="" textDirection="HORIZONTAL" lineWrap="BREAK" vertAlign="CENTER" linkListIDRef="0" linkListNextIDRef="0" textWidth="0" textHeight="0" hasTextRef="0" hasNumRef="0"><hp:p id="0" paraPrIDRef="${para}" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="${head ? 7 : 0}"><hp:t>${esc(plain(String(txt)))}</hp:t></hp:run></hp:p></hp:subList><hp:cellAddr colAddr="${c}" rowAddr="${r}"/><hp:cellSpan colSpan="1" rowSpan="1"/><hp:cellSz width="${w[c]}" height="${H}"/><hp:cellMargin left="510" right="510" top="141" bottom="141"/></hp:tc>`
145
+ }
146
+ const trs = body.map((r, ri) => `<hp:tr>${r.map((x, ci) => cell(x, ri, ci)).join("")}</hp:tr>`).join("")
147
+ const tbl = `<hp:tbl id="${1900000000 + nextId()}" zOrder="0" numberingType="TABLE" textWrap="TOP_AND_BOTTOM" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" pageBreak="CELL" repeatHeader="1" rowCnt="${body.length}" colCnt="${n}" cellSpacing="0" borderFillIDRef="3" noAdjust="0"><hp:sz width="${W}" widthRelTo="ABSOLUTE" height="${H * body.length}" heightRelTo="ABSOLUTE" protect="0"/><hp:pos treatAsChar="0" affectLSpacing="0" flowWithText="1" allowOverlap="0" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="COLUMN" vertAlign="TOP" horzAlign="LEFT" vertOffset="0" horzOffset="0"/><hp:outMargin left="0" right="0" top="283" bottom="283"/><hp:inMargin left="510" right="510" top="141" bottom="141"/>${trs}</hp:tbl>`
148
+ return `<hp:p id="${nextId()}" paraPrIDRef="3" styleIDRef="0" pageBreak="0" columnBreak="0" merged="0"><hp:run charPrIDRef="0">${tbl}<hp:t/></hp:run></hp:p>`
149
+ }
150
+
151
+ // ---------- XLSX ----------
152
+ export async function toXlsx(blocks, title, { font = DEFAULT_FONT } = {}) {
153
+ const zip = new JSZip()
154
+ const sheets = []
155
+ let lastHead = ""
156
+ for (const k of blocks) {
157
+ if (k.type === "h") lastHead = k.text
158
+ if (k.type === "table") sheets.push({ name: lastHead, rows: k.rows })
159
+ }
160
+ const prose = blocks.filter((k) => ["h", "p", "li"].includes(k.type) && k.text).map((k) => [k.type === "li" ? `${marker(k)} ${k.text}` : k.text])
161
+ if (!sheets.length) sheets.push({ name: title, rows: [["내용"], ...prose], text: true })
162
+ else if (prose.length > 1) sheets.push({ name: "내용", rows: [["내용"], ...prose], text: true })
163
+ const docTitle = (blocks.find((k) => k.type === "h" && k.level === 1) || {}).text || title
164
+ const used = new Set()
165
+ const sheetName = (n, i) => { let x = String(n || `표${i + 1}`).replace(/[\[\]:*?/\\]/g, " ").trim().slice(0, 28) || `표${i + 1}`; while (used.has(x)) x = x.slice(0, 25) + "_" + (i + 1); used.add(x); return x }
166
+ const col = (n) => { let s = ""; n++; while (n) { const m = (n - 1) % 26; s = String.fromCharCode(65 + m) + s; n = Math.floor((n - 1) / 26) } return s }
167
+ const cw = (v) => [...String(v)].reduce((a, ch) => a + (/[ᄀ-ᅵ]/.test(ch) ? 2 : 1), 0)
168
+ const numOf = (v) => { const t = String(v).trim().replace(/[,₩\s]/g, "").replace(/^\+/, ""); return /^-?\d+(\.\d+)?$/.test(t) ? t : null }
169
+ const pctOf = (v) => { const m = String(v).trim().replace(/\s/g, "").match(/^([+-]?\d+(?:\.\d+)?)%$/); return m ? String(+(parseFloat(m[1]) / 100).toFixed(8)) : null }
170
+ sheets.forEach((sh, i) => {
171
+ const rows = sh.rows
172
+ const cols = Math.max(...rows.map((r) => r.length))
173
+ const head = 3
174
+ const widths = Array.from({ length: cols }, (_, c) => Math.min(60, Math.max(9, ...rows.map((r) => cw(r[c] ?? "") + 3))))
175
+ const xml = []
176
+ xml.push(`<row r="1" ht="26" customHeight="1"><c r="A1" t="inlineStr" s="2"><is><t>${esc(i === 0 ? docTitle : sh.name || docTitle)}</t></is></c></row>`)
177
+ xml.push(`<row r="2"><c r="A2" t="inlineStr" s="9"><is><t>${esc(today() + " · Palette")}</t></is></c></row>`)
178
+ rows.forEach((r, ri) => {
179
+ const R = head + ri
180
+ const band = ri > 0 && ri % 2 === 0
181
+ xml.push(`<row r="${R}"${ri === 0 ? ' ht="22" customHeight="1"' : ""}>` + Array.from({ length: cols }, (_, ci) => {
182
+ const v = r[ci] ?? ""
183
+ const ref = col(ci) + R
184
+ if (ri === 0) return `<c r="${ref}" t="inlineStr" s="1"><is><t>${esc(v)}</t></is></c>`
185
+ const pc = sh.text ? null : pctOf(v)
186
+ if (pc !== null) return `<c r="${ref}" s="${band ? 11 : 10}"><v>${pc}</v></c>`
187
+ const n = sh.text ? null : numOf(v)
188
+ if (n !== null) return `<c r="${ref}" s="${(n.includes(".") ? 5 : 4) + (band ? 3 : 0)}"><v>${n}</v></c>`
189
+ return `<c r="${ref}" t="inlineStr" s="${band ? 6 : 3}"><is><t>${esc(v)}</t></is></c>`
190
+ }).join("") + "</row>")
191
+ })
192
+ const last = col(cols - 1) + (head + rows.length - 1)
193
+ zip.file(`xl/worksheets/sheet${i + 1}.xml`, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetViews><sheetView workbookViewId="0" showGridLines="0"><pane ySplit="${head}" topLeftCell="A${head + 1}" activePane="bottomLeft" state="frozen"/></sheetView></sheetViews><sheetFormatPr defaultRowHeight="18"/><cols>${widths.map((w, c) => `<col min="${c + 1}" max="${c + 1}" width="${w}" customWidth="1"/>`).join("")}</cols><sheetData>${xml.join("")}</sheetData>${rows.length > 1 && !sh.text ? `<autoFilter ref="A${head}:${last}"/>` : ""}<pageMargins left="0.5" right="0.5" top="0.6" bottom="0.6" header="0.3" footer="0.3"/><pageSetup orientation="landscape" fitToWidth="1" fitToHeight="0"/></worksheet>`)
194
+ sh.title = sheetName(sh.name, i)
195
+ })
196
+ zip.file("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>${sheets.map((sh, i) => `<sheet name="${esc(sh.title)}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join("")}</sheets>${(() => { const d = sheets.map((sh, i) => sh.rows.length > 1 && !sh.text ? `<definedName name="_xlnm._FilterDatabase" localSheetId="${i}" hidden="1">'${esc(sh.title.replace(/'/g, "''"))}'!$A$3:$${col(Math.max(...sh.rows.map((r) => r.length)) - 1)}$${3 + sh.rows.length - 1}</definedName>` : "").join(""); return d ? `<definedNames>${d}</definedNames>` : "" })()}</workbook>`)
197
+ const F = `<name val="${esc(font)}"/><family val="2"/>`
198
+ zip.file("xl/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
199
+ <numFmts count="3"><numFmt numFmtId="164" formatCode="#,##0"/><numFmt numFmtId="165" formatCode="#,##0.0#"/><numFmt numFmtId="166" formatCode="+0.0%;-0.0%;0.0%"/></numFmts>
200
+ <fonts count="4"><font><sz val="10.5"/><color rgb="FF1A1A1A"/>${F}</font><font><b/><sz val="10.5"/><color rgb="FFFFFFFF"/>${F}</font><font><b/><sz val="15"/><color rgb="FF000000"/>${F}</font><font><sz val="9"/><color rgb="FF8A8A8A"/>${F}</font></fonts>
201
+ <fills count="4"><fill><patternFill patternType="none"/></fill><fill><patternFill patternType="gray125"/></fill><fill><patternFill patternType="solid"><fgColor rgb="FF1A1A1A"/></patternFill></fill><fill><patternFill patternType="solid"><fgColor rgb="FFF5F3EF"/></patternFill></fill></fills>
202
+ <borders count="2"><border/><border><bottom style="thin"><color rgb="FFD9D4CC"/></bottom></border></borders>
203
+ <cellStyleXfs count="1"><xf/></cellStyleXfs>
204
+ <cellXfs count="12">
205
+ <xf fontId="0"/>
206
+ <xf fontId="1" fillId="2" borderId="1" applyFont="1" applyFill="1" applyAlignment="1"><alignment horizontal="center" vertical="center" wrapText="1"/></xf>
207
+ <xf fontId="2" applyFont="1"/>
208
+ <xf fontId="0" borderId="1" applyBorder="1" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>
209
+ <xf fontId="0" borderId="1" numFmtId="164" applyNumberFormat="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
210
+ <xf fontId="0" borderId="1" numFmtId="165" applyNumberFormat="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
211
+ <xf fontId="0" fillId="3" borderId="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="center" wrapText="1"/></xf>
212
+ <xf fontId="0" fillId="3" borderId="1" numFmtId="164" applyNumberFormat="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
213
+ <xf fontId="0" fillId="3" borderId="1" numFmtId="165" applyNumberFormat="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
214
+ <xf fontId="3" applyFont="1"/>
215
+ <xf fontId="0" borderId="1" numFmtId="166" applyNumberFormat="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
216
+ <xf fontId="0" fillId="3" borderId="1" numFmtId="166" applyNumberFormat="1" applyFill="1" applyBorder="1" applyAlignment="1"><alignment vertical="center"/></xf>
217
+ </cellXfs><cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles></styleSheet>`)
218
+ zip.file("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet${i + 1}.xml"/>`).join("")}<Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`)
219
+ zip.file("_rels/.rels", '<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>')
220
+ zip.file("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>${sheets.map((_, i) => `<Override PartName="/xl/worksheets/sheet${i + 1}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>`).join("")}</Types>`)
221
+ return zip.generateAsync({ type: "nodebuffer", compression: "DEFLATE" })
222
+ }
223
+
224
+ // ---------- PPTX ----------
225
+ export async function toPptx(blocks, title, outPath, { font = DEFAULT_FONT } = {}) {
226
+ const pptxgen = (await import("pptxgenjs")).default
227
+ const pres = new pptxgen()
228
+ pres.layout = "LAYOUT_16x9"
229
+ pres.theme = { headFontFace: font, bodyFontFace: font }
230
+ const INK = "101516", MUTE = "7B8381", ACC = "006F9C", BG = "F1F0ED"
231
+ const docTitle = (blocks.find((k) => k.type === "h" && k.level === 1) || {}).text || title
232
+ const cover = pres.addSlide()
233
+ cover.background = { color: BG }
234
+ cover.addText(docTitle, { x: 0.6, y: 1.6, w: 8.8, h: 1.6, fontSize: 34, bold: true, color: INK, fontFace: font, isTextBox: true })
235
+ cover.addText(`${today()} · Palette`, { x: 0.6, y: 3.3, w: 8.8, h: 0.5, fontSize: 14, color: MUTE, fontFace: font, isTextBox: true })
236
+ // one slide per heading; the body is bullets + tables
237
+ const sections = []
238
+ let cur = null
239
+ for (const k of blocks) {
240
+ if (k.type === "h" && k.level <= 2) { if (k.level === 1 && k.text === docTitle && !cur) continue; cur = { title: k.text, items: [] }; sections.push(cur); continue }
241
+ if (!cur) { cur = { title: docTitle, items: [] }; sections.push(cur) }
242
+ cur.items.push(k)
243
+ }
244
+ for (const s of sections) {
245
+ const bullets = s.items.filter((k) => ["p", "li", "h", "code"].includes(k.type)).map((k) => ({ text: k.text.length > 160 ? k.text.slice(0, 157) + "…" : k.text, options: { bullet: k.type === "li" ? true : false, breakLine: true, bold: k.type === "h" } }))
246
+ const tables = s.items.filter((k) => k.type === "table")
247
+ const chunks = []
248
+ for (let i = 0; i < bullets.length; i += 7) chunks.push(bullets.slice(i, i + 7))
249
+ if (!chunks.length && !tables.length) chunks.push([])
250
+ chunks.forEach((chunk, ci) => {
251
+ const sl = pres.addSlide()
252
+ sl.background = { color: "FFFFFF" }
253
+ sl.addShape(pres.ShapeType.rect, { x: 0, y: 0, w: 0.12, h: 5.625, fill: { color: ACC } })
254
+ sl.addText(s.title + (chunks.length > 1 ? ` (${ci + 1}/${chunks.length})` : ""), { x: 0.5, y: 0.3, w: 9, h: 0.8, fontSize: 24, bold: true, color: INK, fontFace: font, isTextBox: true })
255
+ if (chunk.length) sl.addText(chunk.map((b) => ({ ...b, options: { ...b.options, fontSize: 16, color: INK, fontFace: font, paraSpaceAfter: 6 } })), { x: 0.5, y: 1.2, w: 9, h: 4, valign: "top", isTextBox: true })
256
+ })
257
+ for (const t of tables) {
258
+ const sl = pres.addSlide()
259
+ sl.background = { color: "FFFFFF" }
260
+ sl.addShape(pres.ShapeType.rect, { x: 0, y: 0, w: 0.12, h: 5.625, fill: { color: ACC } })
261
+ sl.addText(s.title, { x: 0.5, y: 0.3, w: 9, h: 0.8, fontSize: 24, bold: true, color: INK, fontFace: font, isTextBox: true })
262
+ const rows = t.rows.slice(0, 14).map((r, ri) => r.map((c) => ({ text: String(c), options: { bold: ri === 0, color: ri === 0 ? "FFFFFF" : INK, fill: { color: ri === 0 ? "1A1A1A" : ri % 2 === 0 ? "F5F3EF" : "FFFFFF" }, fontSize: 11, fontFace: font } })))
263
+ sl.addTable(rows, { x: 0.5, y: 1.2, w: 9, border: { type: "solid", pt: 0.5, color: "D9D4CC" }, autoPage: false })
264
+ }
265
+ }
266
+ await pres.writeFile({ fileName: outPath })
267
+ return outPath
268
+ }
269
+
270
+ // ---------- entry ----------
271
+ export const NATIVE = ["docx", "hwpx", "xlsx", "pptx", "md", "txt", "csv", "html"]
272
+ export const VIA_LO = { pdf: "docx", doc: "docx", odt: "docx", rtf: "docx", xls: "xlsx", ods: "xlsx", ppt: "pptx", odp: "pptx" }
273
+
274
+ export async function createDocument({ format, out, title = "문서", markdown = "", font = DEFAULT_FONT }) {
275
+ const fmt = String(format || path.extname(out).slice(1)).toLowerCase()
276
+ const blocks = blocksOf(markdown)
277
+ fs.mkdirSync(path.dirname(path.resolve(out)), { recursive: true })
278
+ const write = async (f, target) => {
279
+ if (f === "docx") fs.writeFileSync(target, await toDocx(blocks, title, { font }))
280
+ else if (f === "hwpx") fs.writeFileSync(target, await toHwpx(blocks, title))
281
+ else if (f === "xlsx") fs.writeFileSync(target, await toXlsx(blocks, title, { font }))
282
+ else if (f === "pptx") await toPptx(blocks, title, target, { font })
283
+ else if (f === "md" || f === "txt") fs.writeFileSync(target, toMarkdown(blocks))
284
+ else if (f === "csv") { const t = blocks.find((k) => k.type === "table"); if (!t) throw new Error("csv needs at least one markdown table"); fs.writeFileSync(target, t.rows.map((r) => r.map((c) => /[",\n]/.test(c) ? `"${c.replace(/"/g, '""')}"` : c).join(",")).join("\n") + "\n") }
285
+ else if (f === "html") fs.writeFileSync(target, htmlOf(blocks, title, font))
286
+ else throw new Error(`unsupported format ${f}`)
287
+ }
288
+ if (NATIVE.includes(fmt)) { await write(fmt, out); return { path: out, format: fmt, blocks: blocks.length } }
289
+ if (VIA_LO[fmt]) {
290
+ if (fmt === "hwp") throw new Error("hwp export is not available; create hwpx")
291
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "palette-docs-"))
292
+ const src = path.join(tmp, path.basename(out, path.extname(out)) + "." + VIA_LO[fmt])
293
+ await write(VIA_LO[fmt], src)
294
+ const produced = sofficeConvert(src, fmt, tmp)
295
+ fs.copyFileSync(produced, out)
296
+ return { path: out, format: fmt, blocks: blocks.length, via: "libreoffice" }
297
+ }
298
+ if (fmt === "hwp") throw new Error("hwp (binary 5.0) export needs 한글 or a LibreOffice H2Orestart build; create hwpx instead — 한글 opens it natively")
299
+ throw new Error(`unsupported output .${fmt}; native: ${NATIVE.join(", ")}; via LibreOffice: ${Object.keys(VIA_LO).join(", ")}`)
300
+ }
301
+
302
+ function htmlOf(blocks, title, font) {
303
+ const b = blocks.map((k) => k.type === "h" ? `<h${k.level}>${esc(k.text)}</h${k.level}>` : k.type === "li" ? `<li>${esc(k.text)}</li>` : k.type === "code" ? `<pre><code>${esc(k.text)}</code></pre>` : k.type === "table" ? `<table>${k.rows.map((r, i) => `<tr>${r.map((c) => (i ? `<td>${esc(c)}</td>` : `<th>${esc(c)}</th>`)).join("")}</tr>`).join("")}</table>` : `<p>${esc(k.text)}</p>`).join("\n").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (m) => `<ul>\n${m}</ul>\n`)
304
+ return `<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>${esc(title)}</title><style>body{font-family:"${esc(font)}","Apple SD Gothic Neo","Noto Sans KR",sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;color:#1a1a1a}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid #d9d4cc;padding:6px 10px;text-align:left}th{background:#1a1a1a;color:#fff}pre{background:#f5f3ef;padding:12px;overflow:auto}</style></head><body>\n${b}\n</body></html>\n`
305
+ }
306
+
307
+ /** Convert an existing file to another format: native read + create for text-like targets, LibreOffice otherwise. */
308
+ export async function convertDocument(src, to, outDir) {
309
+ const { readDocument } = await import("./read.js")
310
+ const fmt = String(to).toLowerCase()
311
+ const dir = outDir || path.dirname(path.resolve(src))
312
+ const out = path.join(dir, path.basename(src, path.extname(src)) + "." + fmt)
313
+ const ext = path.extname(src).slice(1).toLowerCase()
314
+ const loDirect = !["hwp", "hwpx"].includes(ext) && (VIA_LO[fmt] || ["docx", "xlsx", "pptx", "html"].includes(fmt))
315
+ if (loDirect) {
316
+ try { const produced = sofficeConvert(src, fmt, dir); if (produced !== out) fs.renameSync(produced, out); return { path: out, via: "libreoffice" } } catch (e) { if (!NATIVE.includes(fmt)) throw e }
317
+ }
318
+ const r = await readDocument(src, { maxChars: 5_000_000 })
319
+ return { ...(await createDocument({ format: fmt, out, title: r.meta?.title || path.basename(src, path.extname(src)), markdown: r.text })), via: "palette-docs" }
320
+ }
@@ -0,0 +1 @@
1
+ blank.hwpx from neolord0/hwpxlib testFile/tool (Apache-2.0)
Binary file