dsh-doc-assistant 0.1.14 → 0.1.18

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,269 @@
1
+ /**
2
+ * Read an existing PPTX cover without starting Office. Image-only slide decks
3
+ * often keep the complete first slide as one JPEG; converting the whole deck
4
+ * just to display that image is unnecessary. This narrow reader never extracts
5
+ * files, follows external relationships, or tries to compose complex slides.
6
+ */
7
+ import { open } from 'node:fs/promises';
8
+ import { posix } from 'node:path';
9
+ import { inflateRawSync } from 'node:zlib';
10
+
11
+ export const PPTX_COVER_VERSION = 'pptx-embedded-cover-v1';
12
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
13
+ const MAX_XML_BYTES = 512 * 1024;
14
+ const crcTable = Uint32Array.from({ length: 256 }, (_, n) => {
15
+ let c = n;
16
+ for (let bit = 0; bit < 8; bit += 1) c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
17
+ return c >>> 0;
18
+ });
19
+ function crc32(bytes) {
20
+ let c = 0xffffffff;
21
+ for (const b of bytes) c = crcTable[(c ^ b) & 255] ^ (c >>> 8);
22
+ return (c ^ 0xffffffff) >>> 0;
23
+ }
24
+
25
+ async function readAt(handle, length, offset) {
26
+ const bytes = Buffer.alloc(length);
27
+ let read = 0;
28
+ while (read < length) {
29
+ const out = await handle.read(bytes, read, length - read, offset + read);
30
+ if (!out.bytesRead) throw new Error('Incomplete ZIP entry');
31
+ read += out.bytesRead;
32
+ }
33
+ return bytes;
34
+ }
35
+
36
+ /** Only the directory and requested entries are read, not all embedded images. */
37
+ async function zipReader(handle) {
38
+ const { size } = await handle.stat();
39
+ if (size < 22 || size > 512 * 1024 * 1024) throw new Error('Unsupported ZIP size');
40
+ const tail = await readAt(handle, Math.min(size, 65557), Math.max(0, size - 65557));
41
+ let eocd = -1;
42
+ for (let at = tail.length - 22; at >= 0; at -= 1) {
43
+ if (tail.readUInt32LE(at) === 0x06054b50 && at + 22 + tail.readUInt16LE(at + 20) === tail.length) {
44
+ eocd = at;
45
+ break;
46
+ }
47
+ }
48
+ if (eocd < 0 || tail.readUInt16LE(eocd + 4) || tail.readUInt16LE(eocd + 6)) throw new Error('Unsupported ZIP directory');
49
+ const count = tail.readUInt16LE(eocd + 10);
50
+ const directorySize = tail.readUInt32LE(eocd + 12);
51
+ const directoryOffset = tail.readUInt32LE(eocd + 16);
52
+ if (count !== tail.readUInt16LE(eocd + 8) || count > 10000 || directorySize > 8 * 1024 * 1024
53
+ || directoryOffset + directorySize > size - tail.length + eocd) throw new Error('Invalid ZIP directory');
54
+ const directory = await readAt(handle, directorySize, directoryOffset);
55
+ const entries = new Map();
56
+ let at = 0;
57
+ for (let n = 0; n < count; n += 1) {
58
+ if (at + 46 > directory.length || directory.readUInt32LE(at) !== 0x02014b50) throw new Error('Invalid ZIP entry');
59
+ const nameLength = directory.readUInt16LE(at + 28);
60
+ const next = at + 46 + nameLength + directory.readUInt16LE(at + 30) + directory.readUInt16LE(at + 32);
61
+ if (next > directory.length) throw new Error('Invalid ZIP entry length');
62
+ const name = directory.subarray(at + 46, at + 46 + nameLength).toString('utf8');
63
+ if (entries.has(name) || name.includes('\0')) throw new Error('Ambiguous ZIP entry');
64
+ entries.set(name, {
65
+ flags: directory.readUInt16LE(at + 8), method: directory.readUInt16LE(at + 10),
66
+ crc: directory.readUInt32LE(at + 16), compressed: directory.readUInt32LE(at + 20),
67
+ size: directory.readUInt32LE(at + 24), offset: directory.readUInt32LE(at + 42),
68
+ });
69
+ at = next;
70
+ }
71
+ if (at !== directory.length) throw new Error('Invalid ZIP directory length');
72
+ async function read(name, limit) {
73
+ const entry = entries.get(name);
74
+ if (!entry) return null;
75
+ if ((entry.flags & 1) || ![0, 8].includes(entry.method) || entry.size > limit || entry.compressed > limit
76
+ || entry.offset + 30 > directoryOffset) throw new Error('Unsupported ZIP entry');
77
+ const head = await readAt(handle, 30, entry.offset);
78
+ if (head.readUInt32LE(0) !== 0x04034b50 || head.readUInt16LE(8) !== entry.method
79
+ || head.readUInt16LE(6) !== entry.flags) throw new Error('Invalid ZIP local entry');
80
+ const nameLength = head.readUInt16LE(26);
81
+ const start = entry.offset + 30 + nameLength + head.readUInt16LE(28);
82
+ if (start + entry.compressed > directoryOffset) throw new Error('Invalid ZIP data bounds');
83
+ const localName = await readAt(handle, nameLength, entry.offset + 30);
84
+ if (localName.toString('utf8') !== name) throw new Error('Conflicting ZIP names');
85
+ const compressed = await readAt(handle, entry.compressed, start);
86
+ const bytes = entry.method === 8 ? inflateRawSync(compressed, { maxOutputLength: limit }) : compressed;
87
+ if (bytes.length !== entry.size || crc32(bytes) !== entry.crc) throw new Error('Invalid ZIP entry checksum');
88
+ return bytes;
89
+ }
90
+ return { entries, read };
91
+ }
92
+
93
+ function xmlDecode(value) {
94
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_all, key) => {
95
+ if (key[0] === '#') {
96
+ const n = Number.parseInt(key.slice(key[1].toLowerCase() === 'x' ? 2 : 1), key[1].toLowerCase() === 'x' ? 16 : 10);
97
+ return n > 0 && n <= 0x10ffff && !(n >= 0xd800 && n <= 0xdfff) ? String.fromCodePoint(n) : '';
98
+ }
99
+ return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[key.toLowerCase()];
100
+ });
101
+ }
102
+ function attrs(text) {
103
+ const out = {};
104
+ for (const m of text.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1]] = xmlDecode(m[2] ?? m[3]);
105
+ return out;
106
+ }
107
+ function tags(xml, name) {
108
+ return [...xml.matchAll(new RegExp(`<(?:[\\w.-]+:)?${name}\\b([^>]*)>`, 'g'))].map(m => attrs(m[1]));
109
+ }
110
+ function blocks(xml, name) {
111
+ return [...xml.matchAll(new RegExp(`<(?:[\\w.-]+:)?${name}\\b[^>]*>[\\s\\S]*?<\\/(?:[\\w.-]+:)?${name}\\s*>`, 'g'))].map(m => m[0]);
112
+ }
113
+ function qualifiedAttribute(obj, name) {
114
+ return Object.entries(obj ?? {}).find(([key]) => key.includes(':') && key.split(':').at(-1) === name)?.[1];
115
+ }
116
+ function relationship(xml, id, type) {
117
+ return tags(xml, 'Relationship').find(r => r.Id === id && r.Type?.endsWith(`/${type}`) && r.TargetMode !== 'External');
118
+ }
119
+ function packageTarget(from, target) {
120
+ if (!target || /[\\\0?#]/.test(target) || /^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith('//')) return null;
121
+ const name = posix.normalize(target.startsWith('/') ? target.slice(1) : posix.join(posix.dirname(from), target));
122
+ return name.startsWith('../') || name === '..' || name.startsWith('/') ? null : name;
123
+ }
124
+ async function readXml(zip, name) {
125
+ const bytes = await zip.read(name, MAX_XML_BYTES);
126
+ if (!bytes) return null;
127
+ const xml = bytes.toString('utf8');
128
+ if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('Unsupported XML declaration');
129
+ return xml;
130
+ }
131
+
132
+ /** Header validation avoids serving arbitrary ZIP contents with an image MIME. */
133
+ export function coverImageInfo(bytes) {
134
+ let width = 0; let height = 0; let mime = '';
135
+ if (bytes.length >= 33 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
136
+ && bytes.toString('ascii', 12, 16) === 'IHDR') {
137
+ width = bytes.readUInt32BE(16); height = bytes.readUInt32BE(20); mime = 'image/png';
138
+ } else if (bytes.length >= 4 && bytes[0] === 255 && bytes[1] === 216) {
139
+ for (let at = 2; at + 4 < bytes.length && at < 256 * 1024;) {
140
+ if (bytes[at] !== 255) return null;
141
+ while (bytes[at] === 255) at += 1;
142
+ const marker = bytes[at++];
143
+ if (marker === 217 || marker === 218) break;
144
+ if (marker === 1 || marker >= 208 && marker <= 215) continue;
145
+ const length = bytes.readUInt16BE(at);
146
+ if (length < 2 || at + length > bytes.length) return null;
147
+ if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker) && length >= 7) {
148
+ height = bytes.readUInt16BE(at + 3); width = bytes.readUInt16BE(at + 5); mime = 'image/jpeg'; break;
149
+ }
150
+ at += length;
151
+ }
152
+ }
153
+ return width > 1 && height > 1 && width <= 20000 && height <= 20000 && width * height <= 32_000_000
154
+ ? { width, height, mime } : null;
155
+ }
156
+
157
+ async function presentationInfo(zip) {
158
+ const presentation = await readXml(zip, 'ppt/presentation.xml');
159
+ const rels = await readXml(zip, 'ppt/_rels/presentation.xml.rels');
160
+ if (!presentation || !rels) return null;
161
+ const size = tags(presentation, 'sldSz')[0];
162
+ const width = Number(size?.cx); const height = Number(size?.cy);
163
+ if (!(width > 0 && height > 0 && width < 1e9 && height < 1e9)) return null;
164
+ const slidePaths = tags(presentation, 'sldId').map(slide => packageTarget('ppt/presentation.xml',
165
+ relationship(rels, qualifiedAttribute(slide, 'id'), 'slide')?.Target));
166
+ if (!slidePaths.length || slidePaths.length > 1000 || slidePaths.some(p => !p)) return null;
167
+ return { width, height, slidePaths };
168
+ }
169
+
170
+ async function fullSlideImage(zip, presentation, page) {
171
+ if (!presentation) return null;
172
+ const { width, height, slidePaths } = presentation;
173
+ const slidePath = slidePaths[page - 1];
174
+ if (!slidePath) return null;
175
+ const slide = await readXml(zip, slidePath);
176
+ const slideRels = await readXml(zip, `${posix.dirname(slidePath)}/_rels/${posix.basename(slidePath)}.rels`);
177
+ if (!slide || !slideRels || blocks(slide, 't').some(t => xmlDecode(t.replace(/<[^>]*>/g, '')).trim())
178
+ || /<(?:[\w.-]+:)?(?:graphicFrame|grpSp|AlternateContent|oleObj)\b/.test(slide)) return null;
179
+ const pictures = blocks(slide, 'pic');
180
+ if (pictures.length !== 1) return null;
181
+ const picture = pictures[0];
182
+ const transform = tags(picture, 'xfrm')[0];
183
+ if (!transform || Number(transform.rot ?? 0) !== 0 || ['flipH', 'flipV'].some(k => transform[k] && !['0', 'false'].includes(transform[k]))) return null;
184
+ const offset = tags(picture, 'off')[0]; const extent = tags(picture, 'ext')[0];
185
+ const near = (a, b, base) => Number.isFinite(Number(a)) && Math.abs(Number(a) - b) <= base * 0.002;
186
+ if (!near(offset?.x, 0, width) || !near(offset?.y, 0, height)
187
+ || !near(extent?.cx, width, width) || !near(extent?.cy, height, height)
188
+ || tags(picture, 'srcRect').some(a => Object.values(a).some(v => Number(v) !== 0))
189
+ || tags(picture, 'prstGeom').some(a => a.prst !== 'rect')) return null;
190
+ // A zero-width border at the slide edge is harmless. Any actual overlay
191
+ // needs Office composition, so do not silently discard titles or logos.
192
+ for (const shape of blocks(slide, 'sp')) {
193
+ const off = tags(shape, 'off')[0]; const ext = tags(shape, 'ext')[0];
194
+ const border = Number(ext?.cx) === 0 && (near(off?.x, 0, width) || near(off?.x, width, width))
195
+ || Number(ext?.cy) === 0 && (near(off?.y, 0, height) || near(off?.y, height, height));
196
+ if (!border) return null;
197
+ }
198
+ const blips = [...picture.matchAll(/<(?:[\w.-]+:)?blip\b([^>]*)(?:\/>|>([\s\S]*?)<\/(?:[\w.-]+:)?blip\s*>)/g)];
199
+ if (blips.length !== 1 || blips[0][2]?.trim()) return null;
200
+ const blip = attrs(blips[0][1]);
201
+ if (qualifiedAttribute(blip, 'link')) return null;
202
+ const imagePath = packageTarget(slidePath, relationship(slideRels, qualifiedAttribute(blip, 'embed'), 'image')?.Target);
203
+ if (!imagePath) return null;
204
+ const bytes = await zip.read(imagePath, MAX_IMAGE_BYTES);
205
+ const info = bytes && coverImageInfo(bytes);
206
+ if (!info || Math.abs((info.width / info.height) / (width / height) - 1) > 0.015) return null;
207
+ return { page, bytes, ...info, source: 'embedded-slide' };
208
+ }
209
+
210
+ /**
211
+ * Ordered full-slide images for OCR/vision as well as covers. Page numbers are
212
+ * one-based and follow presentation order. Complex slides are omitted rather
213
+ * than returning one misleading component of the page. All reads are bounded.
214
+ */
215
+ export async function getPptxSlideImages(abs, options = {}) {
216
+ let handle;
217
+ try {
218
+ handle = await open(abs, 'r');
219
+ const zip = await zipReader(handle);
220
+ const presentation = await presentationInfo(zip);
221
+ if (!presentation) return { totalPages: 0, images: [] };
222
+ const totalPages = presentation.slidePaths.length;
223
+ const wanted = Array.isArray(options.pages) ? options.pages : Array.from({ length: totalPages }, (_, n) => n + 1);
224
+ const pages = [...new Set(wanted)].filter(page => Number.isInteger(page) && page > 0 && page <= totalPages).sort((a, b) => a - b);
225
+ const maxBytes = Math.min(128 * 1024 * 1024, Math.max(0, options.maxBytes ?? 64 * 1024 * 1024));
226
+ let used = 0;
227
+ const images = [];
228
+ for (const page of pages) {
229
+ const image = await fullSlideImage(zip, presentation, page);
230
+ if (!image) continue;
231
+ if (used + image.bytes.length > maxBytes) break;
232
+ used += image.bytes.length;
233
+ images.push(image);
234
+ }
235
+ return { totalPages, images };
236
+ } catch {
237
+ return { totalPages: 0, images: [] };
238
+ } finally {
239
+ await handle?.close().catch(() => {});
240
+ }
241
+ }
242
+
243
+ export async function readSlideImage(abs, page) {
244
+ return (await getPptxSlideImages(abs, { pages: [page] })).images[0] ?? null;
245
+ }
246
+
247
+ /** Null means use the existing renderer; malformed archives never fail a shelf. */
248
+ export async function embeddedPptxCover(abs) {
249
+ let handle;
250
+ try {
251
+ handle = await open(abs, 'r');
252
+ const zip = await zipReader(handle);
253
+ const first = await fullSlideImage(zip, await presentationInfo(zip), 1);
254
+ if (first) return first;
255
+ for (const name of zip.entries.keys()) {
256
+ if (!/^docProps\/thumbnail\.(?:png|jpe?g)$/i.test(name)) continue;
257
+ const bytes = await zip.read(name, MAX_IMAGE_BYTES);
258
+ const info = bytes && coverImageInfo(bytes);
259
+ // Office often stores a 256 px thumbnail. That looks blurred on a 2x
260
+ // shelf card, so only use thumbnails with enough detail for the card.
261
+ if (info && info.width >= 480) return { bytes, ...info, source: 'embedded-thumbnail' };
262
+ }
263
+ return null;
264
+ } catch {
265
+ return null;
266
+ } finally {
267
+ await handle?.close().catch(() => {});
268
+ }
269
+ }