slidemind 0.1.7 → 0.1.9

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.
@@ -0,0 +1,169 @@
1
+ // Pure OOXML helpers over a .pptx entry map (zip path → Uint8Array), shared by
2
+ // read-local.mjs and slide-swap.mjs. No fflate import here — callers hand in an
3
+ // already-unzipped `files` map (see zip.mjs). These are deliberately
4
+ // regex-over-XML, not a DOM: the shapes we touch (sldIdLst, rels, placeholders,
5
+ // text runs) are simple, and @xmldom/xmldom is forbidden by the packaging guard.
6
+
7
+ import { readText } from "./zip.mjs";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Presentation order — the authoritative slide sequence
11
+ // ---------------------------------------------------------------------------
12
+
13
+ // The slide play order as PowerPoint stores it: the `<p:sldId>` entries in
14
+ // `<p:sldIdLst>` (document order), each `r:id` mapped through
15
+ // presentation.xml.rels to its slide part. This is NOT filename order — after a
16
+ // reorder in PowerPoint, slide3.xml can be the first slide. Returns normalized
17
+ // `ppt/slides/slideN.xml` paths, in play order; non-slide targets are dropped.
18
+ export function slideOrderPaths(files) {
19
+ const presXml = readText(files, "ppt/presentation.xml");
20
+ if (!presXml) return [];
21
+
22
+ // Isolate the sldIdLst so a stray r:id elsewhere in the part can't leak in.
23
+ const listMatch = /<p:sldIdLst[\s\S]*?<\/p:sldIdLst>/.exec(presXml);
24
+ if (!listMatch) return [];
25
+
26
+ // r:id in document order (the on-screen slide sequence).
27
+ const rIds = [];
28
+ const idRe = /<p:sldId\b[^>]*\br:id\s*=\s*"([^"]+)"/g;
29
+ let m;
30
+ while ((m = idRe.exec(listMatch[0])) !== null) rIds.push(m[1]);
31
+
32
+ const rels = relsById(files, "ppt/presentation.xml");
33
+ const out = [];
34
+ for (const rId of rIds) {
35
+ const rel = rels[rId];
36
+ if (!rel) continue;
37
+ const resolved = resolvePartPath("ppt", rel.target);
38
+ if (/^ppt\/slides\/slide\d+\.xml$/.test(resolved)) out.push(resolved);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ // The lowest-numbered slide part, `ppt/slides/slideN.xml` with the minimum N, or
44
+ // null if the package has none. This mirrors the add-in render-time splicer
45
+ // byte-for-byte: it picks the lowest-numbered slide part and copies only that
46
+ // slide's dependency tree. Keep this the SINGLE definition of "lowest" so the
47
+ // fold in slide-swap.mjs lands the picked slide exactly where the splicer looks.
48
+ export function firstSlidePath(files) {
49
+ let best = null;
50
+ let bestN = Infinity;
51
+ for (const key of Object.keys(files)) {
52
+ const m = /^ppt\/slides\/slide(\d+)\.xml$/.exec(key);
53
+ if (!m) continue;
54
+ const n = parseInt(m[1], 10);
55
+ if (n < bestN) {
56
+ bestN = n;
57
+ best = key;
58
+ }
59
+ }
60
+ return best;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Relationships
65
+ // ---------------------------------------------------------------------------
66
+
67
+ // Parse the `.rels` for any part into `[{ id, type, target, targetMode }]`;
68
+ // empty array when the part has no rels. The rels part lives beside the part in
69
+ // a `_rels/` sibling dir, named `<base>.rels` (e.g. ppt/slides/slide1.xml →
70
+ // ppt/slides/_rels/slide1.xml.rels).
71
+ export function relsFor(files, partPath) {
72
+ const relsPath = relsPathFor(partPath);
73
+ const xml = readText(files, relsPath);
74
+ if (!xml) return [];
75
+ const out = [];
76
+ const re = /<Relationship\b[^>]*\/?>/g;
77
+ let m;
78
+ while ((m = re.exec(xml)) !== null) {
79
+ const tag = m[0];
80
+ const id = attr(tag, "Id");
81
+ const type = attr(tag, "Type");
82
+ const target = attr(tag, "Target");
83
+ const targetMode = attr(tag, "TargetMode");
84
+ if (id === null && type === null && target === null) continue;
85
+ out.push({ id, type, target, targetMode });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ // relsFor keyed by relationship Id — the r:id → Target lookup slideOrderPaths
91
+ // needs. Internal helper; callers outside this module use relsFor.
92
+ function relsById(files, partPath) {
93
+ const map = {};
94
+ for (const rel of relsFor(files, partPath)) {
95
+ if (rel.id) map[rel.id] = rel;
96
+ }
97
+ return map;
98
+ }
99
+
100
+ // The `_rels/<base>.rels` sibling path for a part. Exported shape is implicit
101
+ // via relsFor/slide-swap; kept internal since callers reason in part paths.
102
+ export function relsPathFor(partPath) {
103
+ const slash = partPath.lastIndexOf("/");
104
+ const dir = slash === -1 ? "" : partPath.slice(0, slash);
105
+ const base = slash === -1 ? partPath : partPath.slice(slash + 1);
106
+ return `${dir ? dir + "/" : ""}_rels/${base}.rels`;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Path + entity primitives
111
+ // ---------------------------------------------------------------------------
112
+
113
+ // Resolve a rels Target to a normalized zip path (no leading slash). Targets are
114
+ // either relative to the part's own dir (`../slideLayouts/slideLayout1.xml`,
115
+ // `slides/slide3.xml`) or package-absolute (`/ppt/slides/slide3.xml`). `fromDir`
116
+ // is the directory the relative target is anchored at (e.g. "ppt" for
117
+ // presentation.xml.rels, "ppt/slides" for a slide's rels).
118
+ export function resolvePartPath(fromDir, target) {
119
+ if (target == null) return "";
120
+ let t = String(target).trim();
121
+ // Package-absolute: drop the leading slash, ignore fromDir.
122
+ if (t.startsWith("/")) return normalizeSegments(t.slice(1).split("/"));
123
+ const base = fromDir ? fromDir.split("/").filter(Boolean) : [];
124
+ return normalizeSegments(base.concat(t.split("/")));
125
+ }
126
+
127
+ // Collapse "." / ".." segments in a split path into a clean slash-joined string.
128
+ function normalizeSegments(parts) {
129
+ const out = [];
130
+ for (const seg of parts) {
131
+ if (seg === "" || seg === ".") continue;
132
+ if (seg === "..") {
133
+ out.pop();
134
+ continue;
135
+ }
136
+ out.push(seg);
137
+ }
138
+ return out.join("/");
139
+ }
140
+
141
+ // Decode the five predefined XML entities plus numeric character references
142
+ // (`&#233;`, `&#xE9;`). Ampersand is resolved LAST so a decoded `&` from a
143
+ // numeric ref can't be re-read as the head of another entity.
144
+ export function decodeXmlEntities(s) {
145
+ if (s == null) return s;
146
+ return String(s)
147
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => codePoint(parseInt(h, 16)))
148
+ .replace(/&#(\d+);/g, (_, d) => codePoint(parseInt(d, 10)))
149
+ .replace(/&lt;/g, "<")
150
+ .replace(/&gt;/g, ">")
151
+ .replace(/&quot;/g, '"')
152
+ .replace(/&apos;/g, "'")
153
+ .replace(/&amp;/g, "&");
154
+ }
155
+
156
+ function codePoint(n) {
157
+ if (!Number.isFinite(n) || n < 0 || n > 0x10ffff) return "";
158
+ try {
159
+ return String.fromCodePoint(n);
160
+ } catch {
161
+ return "";
162
+ }
163
+ }
164
+
165
+ // Read one double-quoted attribute off a start tag, or null if absent.
166
+ function attr(tag, name) {
167
+ const m = new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`).exec(tag);
168
+ return m ? m[1] : null;
169
+ }
@@ -0,0 +1,206 @@
1
+ // Host-free `slidemind read <file.pptx>`: parse a .pptx on disk into the SAME
2
+ // `{ ok, mode, slideCount, slides[] }` shape the add-in returns for an open
3
+ // presentation (test/read-presentation.test.mjs), so the CLI printer is shared.
4
+ // Everything runs locally over the unzipped entry map — no bridge, no host.
5
+ //
6
+ // All fields are computed regardless of mode; the CLI printer selects what to
7
+ // show. `notes` is additive for `--json` consumers and is always populated here.
8
+
9
+ import { unzip, readText } from "./zip.mjs";
10
+ import {
11
+ slideOrderPaths,
12
+ relsFor,
13
+ resolvePartPath,
14
+ decodeXmlEntities,
15
+ } from "./ooxml.mjs";
16
+
17
+ // Parse a .pptx buffer. Throws a clear, CLI-printable message when the bytes
18
+ // aren't a .pptx (missing presentation.xml) or aren't a zip at all — the CLI
19
+ // prints error.message verbatim.
20
+ export function readLocalPptx(buffer, { mode = "raw" } = {}) {
21
+ let files;
22
+ try {
23
+ files = unzip(buffer);
24
+ } catch (err) {
25
+ // fflate's own reason ("invalid zip data") — surface it, framed as .pptx.
26
+ throw new Error(`not a valid .pptx: ${err.message}`);
27
+ }
28
+ if (!files["ppt/presentation.xml"]) {
29
+ throw new Error("not a valid .pptx (missing ppt/presentation.xml)");
30
+ }
31
+
32
+ const order = slideOrderPaths(files);
33
+ const slides = order.map((slidePath, i) => {
34
+ const xml = readText(files, slidePath) ?? "";
35
+ const { title, subtitle, text } = slideText(xml);
36
+ return {
37
+ index: i + 1,
38
+ layout: layoutName(files, slidePath),
39
+ title,
40
+ subtitle,
41
+ text,
42
+ notes: notesText(files, slidePath),
43
+ };
44
+ });
45
+
46
+ return { ok: true, mode, slideCount: slides.length, slides };
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Text extraction
51
+ // ---------------------------------------------------------------------------
52
+
53
+ // Split a slide's XML into its text and pull title/subtitle/body. The host
54
+ // read's raw `text` is the FULL slide text, so a title/subtitle run counts in
55
+ // both the dedicated field and in `text`.
56
+ // - title: a shape whose <p:ph type="title"|"ctrTitle">.
57
+ // - subtitle: a shape whose <p:ph type="subTitle">.
58
+ // - text: every paragraph's runs, one line per <a:p>, tables tab/newline
59
+ // joined, empty paragraphs dropped, entities decoded.
60
+ export function slideText(slideXml) {
61
+ // Drop <mc:Fallback> branches up front: an AlternateContent block stores the
62
+ // same shape twice (Choice = canonical, Fallback = legacy rendering), so a
63
+ // text-bearing fallback would double-print every run.
64
+ slideXml = stripFallbacks(slideXml);
65
+ let title = null;
66
+ let subtitle = null;
67
+
68
+ // Title/subtitle come from placeholder shapes (<p:sp> carrying <p:ph>). They
69
+ // are single outline labels: an <a:br/> or paragraph break inside the
70
+ // placeholder is visual line-wrapping, not content structure — collapse to one
71
+ // line so the summary printer stays one line per slide. Body `text` keeps its
72
+ // breaks.
73
+ for (const sp of matchAll(slideXml, /<p:sp\b[\s\S]*?<\/p:sp>/g)) {
74
+ const ph = /<p:ph\b[^>]*\btype\s*=\s*"([^"]+)"/.exec(sp);
75
+ const phType = ph ? ph[1] : null;
76
+ if (phType === "title" || phType === "ctrTitle") {
77
+ if (title === null) title = outlineLabel(sp);
78
+ } else if (phType === "subTitle") {
79
+ if (subtitle === null) subtitle = outlineLabel(sp);
80
+ }
81
+ }
82
+
83
+ // Full slide text = every text-bearing container in document order: shapes
84
+ // (<p:sp>) and tables (<a:tbl> inside <p:graphicFrame>). Walking the raw XML
85
+ // once keeps shapes and tables in their on-slide order.
86
+ const lines = [];
87
+ const containerRe = /<p:sp\b[\s\S]*?<\/p:sp>|<a:tbl\b[\s\S]*?<\/a:tbl>/g;
88
+ for (const chunk of matchAll(slideXml, containerRe)) {
89
+ if (chunk.startsWith("<a:tbl")) {
90
+ const t = tableText(chunk);
91
+ if (t) lines.push(t);
92
+ } else {
93
+ for (const line of shapeParagraphText(chunk)) lines.push(line);
94
+ }
95
+ }
96
+
97
+ return { title, subtitle, text: lines.join("\n") };
98
+ }
99
+
100
+ // A placeholder's text flattened to a single-line label (line breaks → spaces,
101
+ // runs of whitespace collapsed), or null when empty.
102
+ function outlineLabel(spXml) {
103
+ const label = shapeParagraphText(spXml)
104
+ .join(" ")
105
+ .replace(/\s+/g, " ")
106
+ .trim();
107
+ return label || null;
108
+ }
109
+
110
+ // The non-empty paragraph lines of a text body: one line per <a:p>, its <a:t>
111
+ // runs concatenated with <a:br/> rendered as a newline, entities decoded.
112
+ // Whitespace-only paragraphs are dropped.
113
+ function shapeParagraphText(xml) {
114
+ const out = [];
115
+ for (const p of matchAll(xml, /<a:p\b[\s\S]*?<\/a:p>/g)) {
116
+ const line = paragraphLine(p);
117
+ if (line.trim() !== "") out.push(line);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ // One paragraph → its text, with <a:br/> as newline. We walk runs and breaks in
123
+ // document order so a break lands between the right runs.
124
+ function paragraphLine(pXml) {
125
+ let s = "";
126
+ const re = /<a:t>([\s\S]*?)<\/a:t>|<a:br\b[^>]*\/?>/g;
127
+ let m;
128
+ while ((m = re.exec(pXml)) !== null) {
129
+ if (m[1] !== undefined) s += decodeXmlEntities(m[1]);
130
+ else s += "\n";
131
+ }
132
+ return s;
133
+ }
134
+
135
+ // A table (<a:tbl>): cells tab-joined within a row, rows newline-joined. Each
136
+ // cell's own text is its paragraph lines joined by newline (rare but possible).
137
+ function tableText(tblXml) {
138
+ const rows = [];
139
+ for (const tr of matchAll(tblXml, /<a:tr\b[\s\S]*?<\/a:tr>/g)) {
140
+ const cells = [];
141
+ for (const tc of matchAll(tr, /<a:tc\b[\s\S]*?<\/a:tc>/g)) {
142
+ cells.push(shapeParagraphText(tc).join("\n"));
143
+ }
144
+ rows.push(cells.join("\t"));
145
+ }
146
+ return rows.join("\n");
147
+ }
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // Layout + notes (resolved through the slide's rels)
151
+ // ---------------------------------------------------------------------------
152
+
153
+ // The slide's layout name: follow the slide → slideLayout relationship, then
154
+ // read the layout's <p:cSld name="…">. Null when there's no layout rel or the
155
+ // layout has no name.
156
+ export function layoutName(files, slidePath) {
157
+ const layoutPath = relTarget(files, slidePath, "/slideLayout");
158
+ if (!layoutPath) return null;
159
+ const xml = readText(files, layoutPath);
160
+ if (!xml) return null;
161
+ const m = /<p:cSld\b[^>]*\bname\s*=\s*"([^"]*)"/.exec(xml);
162
+ return m ? decodeXmlEntities(m[1]) : null;
163
+ }
164
+
165
+ // The slide's speaker notes text, or null when no notesSlide is attached.
166
+ // Extracted with the same paragraph/table rules as the slide body.
167
+ export function notesText(files, slidePath) {
168
+ const notesPath = relTarget(files, slidePath, "/notesSlide");
169
+ if (!notesPath) return null;
170
+ let xml = readText(files, notesPath);
171
+ if (!xml) return null;
172
+ xml = stripFallbacks(xml);
173
+ const lines = [];
174
+ for (const sp of matchAll(xml, /<p:sp\b[\s\S]*?<\/p:sp>/g)) {
175
+ for (const line of shapeParagraphText(sp)) lines.push(line);
176
+ }
177
+ return lines.join("\n");
178
+ }
179
+
180
+ // Resolve the single related part whose Type ends with `typeSuffix`
181
+ // (e.g. "/slideLayout", "/notesSlide") to a normalized zip path, or null.
182
+ function relTarget(files, slidePath, typeSuffix) {
183
+ const fromDir = slidePath.slice(0, slidePath.lastIndexOf("/"));
184
+ for (const rel of relsFor(files, slidePath)) {
185
+ if (rel.type && rel.type.endsWith(typeSuffix)) {
186
+ return resolvePartPath(fromDir, rel.target);
187
+ }
188
+ }
189
+ return null;
190
+ }
191
+
192
+ // Remove every <mc:Fallback>…</mc:Fallback> block. Fallbacks re-encode their
193
+ // AlternateContent's Choice branch for older consumers, so their runs are
194
+ // duplicates by definition — extracting both would double the text.
195
+ function stripFallbacks(xml) {
196
+ if (!xml.includes("<mc:Fallback")) return xml;
197
+ return xml.replace(/<mc:Fallback>[\s\S]*?<\/mc:Fallback>/g, "");
198
+ }
199
+
200
+ // String.prototype.matchAll over a global regex, yielding the full match text.
201
+ function matchAll(str, re) {
202
+ const out = [];
203
+ let m;
204
+ while ((m = re.exec(str)) !== null) out.push(m[0]);
205
+ return out;
206
+ }