diff --git a/src/islands/dev/CidrCalculator.tsx b/src/islands/dev/CidrCalculator.tsx index 45fc3e6..7e09140 100644 --- a/src/islands/dev/CidrCalculator.tsx +++ b/src/islands/dev/CidrCalculator.tsx @@ -1,44 +1,51 @@ import { useMemo, useState } from 'react'; import { Alert } from '@/components/ui/Alert'; import { CopyButton } from '@/components/ui/CopyButton'; -import { parseCidr, type CidrInfo } from '@/tools/dev/cidr.lib'; +import { parseCidrAny, type CidrInfo, type Ipv6Info } from '@/tools/dev/cidr.lib'; import type { Lang } from '@/i18n/config'; const TR: Record, string>; hosts: string; + labels: Record; }> = { en: { - intro: 'Enter an IPv4 address with a CIDR prefix (e.g. 192.168.1.0/24) to see the network, broadcast, mask, host range and count. Everything is computed in your browser.', - label: 'IPv4 address / CIDR', - invalid: 'Enter a valid IPv4 address and prefix, e.g. 10.0.0.0/24.', - rows: { - address: 'Address', netmask: 'Netmask', wildcard: 'Wildcard mask', network: 'Network address', - broadcast: 'Broadcast address', firstHost: 'First usable host', lastHost: 'Last usable host', - totalHosts: 'Total addresses', usableHosts: 'Usable hosts', ipClass: 'Class', - }, + intro: 'Enter an IPv4 or IPv6 address with a CIDR prefix (e.g. 192.168.1.0/24 or 2001:db8::/32) to see the network, range, mask and address count. Everything is computed in your browser.', + label: 'IPv4 / IPv6 address / CIDR', + invalid: 'Enter a valid IPv4 or IPv6 address and prefix, e.g. 10.0.0.0/24 or 2001:db8::/48.', hosts: 'hosts', + labels: { + address: 'Address', fullAddress: 'Expanded address', netmask: 'Netmask', wildcard: 'Wildcard mask', + network: 'Network address', broadcast: 'Broadcast address', firstHost: 'First usable host', + lastHost: 'Last usable host', firstAddress: 'First address', lastAddress: 'Last address', + totalHosts: 'Total addresses', usableHosts: 'Usable hosts', totalAddresses: 'Total addresses', + ipClass: 'Class', prefix: 'Prefix', + }, }, id: { - intro: 'Masukkan alamat IPv4 dengan prefix CIDR (mis. 192.168.1.0/24) untuk melihat network, broadcast, mask, rentang host, dan jumlahnya. Semua dihitung di browser Anda.', - label: 'Alamat IPv4 / CIDR', - invalid: 'Masukkan alamat IPv4 dan prefix yang valid, mis. 10.0.0.0/24.', - rows: { - address: 'Alamat', netmask: 'Netmask', wildcard: 'Wildcard mask', network: 'Alamat network', - broadcast: 'Alamat broadcast', firstHost: 'Host pertama', lastHost: 'Host terakhir', - totalHosts: 'Total alamat', usableHosts: 'Host tersedia', ipClass: 'Kelas', - }, + intro: 'Masukkan alamat IPv4 atau IPv6 dengan prefix CIDR (mis. 192.168.1.0/24 atau 2001:db8::/32) untuk melihat network, rentang, mask, dan jumlah alamat. Semua dihitung di browser Anda.', + label: 'Alamat IPv4 / IPv6 / CIDR', + invalid: 'Masukkan alamat IPv4 atau IPv6 dan prefix yang valid, mis. 10.0.0.0/24 atau 2001:db8::/48.', hosts: 'host', + labels: { + address: 'Alamat', fullAddress: 'Alamat lengkap', netmask: 'Netmask', wildcard: 'Wildcard mask', + network: 'Alamat network', broadcast: 'Alamat broadcast', firstHost: 'Host pertama', + lastHost: 'Host terakhir', firstAddress: 'Alamat pertama', lastAddress: 'Alamat terakhir', + totalHosts: 'Total alamat', usableHosts: 'Host tersedia', totalAddresses: 'Total alamat', + ipClass: 'Kelas', prefix: 'Prefix', + }, }, }; -const ORDER: (keyof Omit)[] = [ +const V4_ORDER: (keyof CidrInfo)[] = [ 'address', 'network', 'broadcast', 'netmask', 'wildcard', 'firstHost', 'lastHost', 'usableHosts', 'totalHosts', 'ipClass', ]; +const V6_ORDER: (keyof Ipv6Info)[] = [ + 'address', 'fullAddress', 'network', 'firstAddress', 'lastAddress', 'totalAddresses', +]; export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) { const t = TR[lang] ?? TR.en; @@ -46,16 +53,21 @@ export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) { const parsed = useMemo(() => { try { - return { info: parseCidr(value) }; + return { info: parseCidrAny(value) }; } catch { return { error: true as const }; } }, [value]); const info = 'info' in parsed ? parsed.info : undefined; - const copyText = info - ? ORDER.map(k => `${t.rows[k]}: ${info[k]}`).join('\n') - : ''; + const order = info ? (info.version === 6 ? V6_ORDER : V4_ORDER) : []; + const rows = order.map(k => ({ + key: k as string, + label: t.labels[k as string] ?? (k as string), + value: String((info as unknown as Record)[k as string]), + suffix: (k === 'totalHosts' || k === 'usableHosts' || k === 'totalAddresses') ? ` ${t.hosts}` : '', + })); + const copyText = rows.map(r => `${r.label}: ${r.value}${r.suffix}`).join('\n'); return (
@@ -68,7 +80,7 @@ export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) { onChange={e => setValue(e.target.value)} spellCheck={false} className="w-full border-2 border-border bg-muted p-3 font-mono text-sm" - placeholder="192.168.1.0/24" + placeholder="192.168.1.0/24 or 2001:db8::/32" /> @@ -77,16 +89,14 @@ export default function CidrCalculator({ lang = 'en' }: { lang?: Lang }) { {info && (
- /{info.prefix} + IPv{info.version} · /{info.prefix}
- {ORDER.map(k => ( -
- {t.rows[k]} - - {info[k]}{(k === 'totalHosts' || k === 'usableHosts') ? ` ${t.hosts}` : ''} - + {rows.map(r => ( +
+ {r.label} + {r.value}{r.suffix}
))}
diff --git a/src/islands/documents/PptxViewer.tsx b/src/islands/documents/PptxViewer.tsx index 974c7d5..b9e1e98 100644 --- a/src/islands/documents/PptxViewer.tsx +++ b/src/islands/documents/PptxViewer.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Dropzone } from '@/components/ui/Dropzone'; import { Alert } from '@/components/ui/Alert'; -import type { PptxDoc } from '@/tools/documents/pptx.lib'; +import type { PptxDoc, Shape } from '@/tools/documents/pptx.lib'; import type { Lang } from '@/i18n/config'; +const PT_TO_PX = 1.3333; + const TR: Record = { en: { - intro: 'Open and read PowerPoint (.pptx) slides in your browser — text and images, slide by slide. The file is never uploaded.', + intro: 'Open and view PowerPoint (.pptx) slides in your browser, laid out with their real positions, text and images. The file is never uploaded.', drop: 'Drop a .pptx file or click to browse', dropSub: 'Opened on your device', loading: 'Opening presentation…', failed: 'Could not open this file. Make sure it is a .pptx presentation.', slide: 'Slide', - empty: '(no text on this slide)', - note: 'This is a lightweight text-and-image viewer, not a pixel-perfect renderer.', + note: 'Lightweight viewer: positions, text and images are rendered; charts, SmartArt and effects are not.', }, id: { - intro: 'Buka dan baca slide PowerPoint (.pptx) di browser Anda — teks dan gambar, slide demi slide. File tidak pernah diunggah.', + intro: 'Buka dan lihat slide PowerPoint (.pptx) di browser Anda, ditata sesuai posisi, teks, dan gambar aslinya. File tidak pernah diunggah.', drop: 'Letakkan file .pptx atau klik untuk memilih', dropSub: 'Dibuka di perangkat Anda', loading: 'Membuka presentasi…', failed: 'Tidak dapat membuka file ini. Pastikan berupa presentasi .pptx.', slide: 'Slide', - empty: '(tidak ada teks pada slide ini)', - note: 'Ini penampil teks-dan-gambar ringan, bukan renderer yang presisi piksel.', + note: 'Penampil ringan: posisi, teks, dan gambar dirender; chart, SmartArt, dan efek tidak.', }, }; +function ShapeView({ shape, url }: { shape: Shape; url?: string }) { + const box: React.CSSProperties = { + position: 'absolute', + left: shape.x, + top: shape.y, + width: shape.w, + height: shape.h, + overflow: 'hidden', + }; + if (shape.kind === 'image') { + return url ? : null; + } + return ( +
+ {shape.paragraphs!.map((p, pi) => ( +

+ {p.runs.map((r, ri) => ( + {r.text} + ))} +

+ ))} +
+ ); +} + export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) { const t = TR[lang] ?? TR.en; const [doc, setDoc] = useState(null); const [mediaUrls, setMediaUrls] = useState>({}); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [scale, setScale] = useState(1); + const wrapRef = useRef(null); useEffect(() => () => { Object.values(mediaUrls).forEach(URL.revokeObjectURL); }, [mediaUrls]); + // Scale the native-size slides to fit the container width. + useEffect(() => { + const el = wrapRef.current; + if (!el || !doc || doc.widthPx === 0) return; + const update = () => setScale(el.clientWidth / doc.widthPx); + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => ro.disconnect(); + }, [doc]); + const onDrop = async (files: File[]) => { const file = files.find(f => f.name.toLowerCase().endsWith('.pptx')); if (!file) return; @@ -84,25 +126,28 @@ export default function PptxViewer({ lang = 'en' }: { lang?: Lang }) { {error && {error}} {doc && ( -
+

{t.note}

{doc.slides.map((slide, i) => ( -
-
{t.slide} {i + 1}
- {slide.paragraphs.length > 0 ? ( - slide.paragraphs.map((p, pi) => ( -

{p}

- )) - ) : ( -

{t.empty}

- )} - {slide.images.length > 0 && ( -
- {slide.images.map((key, ii) => ( - mediaUrls[key] ? : null +
+
{t.slide} {i + 1}
+
+
+ {slide.shapes.map((shape, si) => ( + ))}
- )} +
))}
diff --git a/src/registry/tool-seo.ts b/src/registry/tool-seo.ts index 0d63771..4056c5c 100644 --- a/src/registry/tool-seo.ts +++ b/src/registry/tool-seo.ts @@ -182,7 +182,7 @@ const en: Record = { 'pptx-viewer': { title: 'Free PPTX Viewer — Open PowerPoint Slides Online', description: 'Open and read PowerPoint (.pptx) slides in your browser — text and images, slide by slide. The file is never uploaded.', - intro: 'This free PPTX viewer opens PowerPoint presentations and shows each slide’s text and images, slide by slide, without PowerPoint or an account. It is a lightweight text-and-image viewer (not a pixel-perfect renderer) and runs entirely in your browser, so your file is never uploaded.', + intro: 'This free PPTX viewer opens PowerPoint presentations and renders each slide with its real layout — shapes, text and images in their actual positions — without PowerPoint or an account. It is a lightweight viewer (charts, SmartArt and effects are not rendered) and runs entirely in your browser, so your file is never uploaded.', howTo: [ 'Drop a .pptx file, or click to browse.', 'The presentation opens on your device.', @@ -1879,7 +1879,7 @@ const id: Record = { 'pptx-viewer': { title: 'Penampil PPTX Gratis — Buka Slide PowerPoint Online', description: 'Buka dan baca slide PowerPoint (.pptx) di browser Anda — teks dan gambar, slide demi slide. File tidak pernah diunggah.', - intro: 'Tool penampil PPTX gratis ini membuka presentasi PowerPoint dan menampilkan teks serta gambar tiap slide, slide demi slide, tanpa PowerPoint atau akun. Ini penampil teks-dan-gambar ringan (bukan renderer presisi piksel) dan berjalan sepenuhnya di browser Anda, jadi file Anda tidak pernah diunggah.', + intro: 'Tool penampil PPTX gratis ini membuka presentasi PowerPoint dan merender tiap slide dengan tata letak aslinya — bentuk, teks, dan gambar pada posisi sebenarnya — tanpa PowerPoint atau akun. Ini penampil ringan (chart, SmartArt, dan efek tidak dirender) dan berjalan sepenuhnya di browser Anda, jadi file Anda tidak pernah diunggah.', howTo: [ 'Letakkan berkas .pptx, atau klik untuk memilih.', 'Presentasi terbuka di perangkat Anda.', diff --git a/src/tools/dev/cidr.lib.test.ts b/src/tools/dev/cidr.lib.test.ts index 91efcff..d944a78 100644 --- a/src/tools/dev/cidr.lib.test.ts +++ b/src/tools/dev/cidr.lib.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { parseCidr } from './cidr.lib'; +import { parseCidr, parseIpv6Cidr, parseCidrAny } from './cidr.lib'; describe('parseCidr', () => { it('computes a /24 network', () => { @@ -41,4 +41,52 @@ describe('parseCidr', () => { expect(() => parseCidr('1.2.3.4/33')).toThrow(); expect(() => parseCidr('not-an-ip')).toThrow(); }); + + it('tags the version', () => { + expect(parseCidr('10.0.0.0/8').version).toBe(4); + }); +}); + +describe('parseIpv6Cidr', () => { + it('computes a /32 network', () => { + const r = parseIpv6Cidr('2001:db8::/32'); + expect(r.version).toBe(6); + expect(r.network).toBe('2001:db8::'); + expect(r.firstAddress).toBe('2001:db8::'); + expect(r.lastAddress).toBe('2001:db8:ffff:ffff:ffff:ffff:ffff:ffff'); + expect(r.totalAddresses).toBe((2n ** 96n).toString()); + expect(r.prefix).toBe(32); + }); + + it('handles a single host /128 and :: compression', () => { + const r = parseIpv6Cidr('::1/128'); + expect(r.address).toBe('::1'); + expect(r.network).toBe('::1'); + expect(r.totalAddresses).toBe('1'); + }); + + it('masks a /64 correctly', () => { + expect(parseIpv6Cidr('fe80::abcd:1234/64').network).toBe('fe80::'); + }); + + it('expands the full address form', () => { + expect(parseIpv6Cidr('2001:db8::1/64').fullAddress).toBe('2001:0db8:0000:0000:0000:0000:0000:0001'); + }); + + it('accepts an embedded IPv4 tail', () => { + expect(() => parseIpv6Cidr('::ffff:192.168.1.1/128')).not.toThrow(); + }); + + it('rejects invalid IPv6', () => { + expect(() => parseIpv6Cidr('gggg::/16')).toThrow(); + expect(() => parseIpv6Cidr('2001:db8::/129')).toThrow(); + expect(() => parseIpv6Cidr('1::2::3/64')).toThrow(); + }); +}); + +describe('parseCidrAny', () => { + it('dispatches by address family', () => { + expect(parseCidrAny('192.168.0.0/24').version).toBe(4); + expect(parseCidrAny('2001:db8::/48').version).toBe(6); + }); }); diff --git a/src/tools/dev/cidr.lib.ts b/src/tools/dev/cidr.lib.ts index 634ed54..f38ac95 100644 --- a/src/tools/dev/cidr.lib.ts +++ b/src/tools/dev/cidr.lib.ts @@ -3,6 +3,7 @@ */ export interface CidrInfo { + version: 4; address: string; prefix: number; netmask: string; @@ -71,6 +72,7 @@ export function parseCidr(input: string): CidrInfo { } return { + version: 4, address: intToIp(ip), prefix, netmask: intToIp(netmask), @@ -84,3 +86,111 @@ export function parseCidr(input: string): CidrInfo { ipClass: classOf((ip >>> 24) & 255), }; } + +// --- IPv6 -------------------------------------------------------------------- + +export interface Ipv6Info { + version: 6; + address: string; // compressed + fullAddress: string; // fully expanded + prefix: number; + network: string; + firstAddress: string; + lastAddress: string; + totalAddresses: string; // may be astronomically large → decimal string +} + +/** Expand an IPv6 string to its eight 16-bit groups, handling :: and embedded IPv4. */ +function ipv6Groups(addr: string): number[] { + let a = addr.trim(); + + // Fold a trailing dotted-quad (e.g. ::ffff:192.168.1.1) into two hex groups. + const v4 = a.match(/^(.*:)((?:\d{1,3}\.){3}\d{1,3})$/); + if (v4) { + const octets = v4[2].split('.').map(Number); + if (octets.some(o => o > 255)) throw new Error('Invalid embedded IPv4'); + a = `${v4[1]}${((octets[0] << 8) | octets[1]).toString(16)}:${((octets[2] << 8) | octets[3]).toString(16)}`; + } + + let groups: string[]; + if (a.includes('::')) { + const halves = a.split('::'); + if (halves.length > 2) throw new Error('Invalid IPv6: multiple "::"'); + const head = halves[0] ? halves[0].split(':') : []; + const tail = halves[1] ? halves[1].split(':') : []; + const fill = 8 - head.length - tail.length; + if (fill < 1) throw new Error('Invalid IPv6: "::" must cover at least one group'); + groups = [...head, ...Array(fill).fill('0'), ...tail]; + } else { + groups = a.split(':'); + } + + if (groups.length !== 8) throw new Error('Invalid IPv6 address length'); + return groups.map(g => { + if (!/^[0-9a-fA-F]{1,4}$/.test(g)) throw new Error(`Invalid IPv6 group: ${g}`); + return parseInt(g, 16); + }); +} + +function ipv6ToBig(addr: string): bigint { + return ipv6Groups(addr).reduce((n, g) => (n << 16n) | BigInt(g), 0n); +} + +function bigToGroups(n: bigint): number[] { + const g = new Array(8); + for (let i = 7; i >= 0; i--) { g[i] = Number(n & 0xffffn); n >>= 16n; } + return g; +} + +function expandIpv6(n: bigint): string { + return bigToGroups(n).map(g => g.toString(16).padStart(4, '0')).join(':'); +} + +/** Compress to the canonical shortest form (RFC 5952): longest zero run → "::". */ +function compressIpv6(n: bigint): string { + const g = bigToGroups(n).map(x => x.toString(16)); + let bestStart = -1, bestLen = 0, curStart = -1, curLen = 0; + for (let i = 0; i < 8; i++) { + if (g[i] === '0') { + if (curStart < 0) curStart = i; + curLen++; + if (curLen > bestLen) { bestLen = curLen; bestStart = curStart; } + } else { + curStart = -1; curLen = 0; + } + } + if (bestLen < 2) return g.join(':'); + const before = g.slice(0, bestStart).join(':'); + const after = g.slice(bestStart + bestLen).join(':'); + return `${before}::${after}`; +} + +/** Parse an IPv6 address with optional /prefix (defaults to /128). */ +export function parseIpv6Cidr(input: string): Ipv6Info { + const [addr, prefixStr = '128'] = input.trim().split('/'); + if (!/^\d{1,3}$/.test(prefixStr)) throw new Error(`Invalid prefix: ${prefixStr}`); + const prefix = Number(prefixStr); + if (prefix > 128) throw new Error('Prefix must be between 0 and 128.'); + + const ip = ipv6ToBig(addr); + const hostBits = BigInt(128 - prefix); + const mask = prefix === 0 ? 0n : (((1n << BigInt(prefix)) - 1n) << hostBits); + const network = ip & mask; + const last = network | ((1n << hostBits) - 1n); + + return { + version: 6, + address: compressIpv6(ip), + fullAddress: expandIpv6(ip), + prefix, + network: compressIpv6(network), + firstAddress: compressIpv6(network), + lastAddress: compressIpv6(last), + totalAddresses: (1n << hostBits).toString(), + }; +} + +/** Parse an IPv4 or IPv6 CIDR, dispatching by address family. */ +export function parseCidrAny(input: string): CidrInfo | Ipv6Info { + return input.includes(':') ? parseIpv6Cidr(input) : parseCidr(input); +} diff --git a/src/tools/documents/pptx.lib.test.ts b/src/tools/documents/pptx.lib.test.ts index 958766c..8373d7b 100644 --- a/src/tools/documents/pptx.lib.test.ts +++ b/src/tools/documents/pptx.lib.test.ts @@ -2,48 +2,85 @@ import { describe, it, expect } from 'vitest'; import { zipSync, strToU8 } from 'fflate'; import { parsePptx } from './pptx.lib'; -const NS = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"'; - -function slideXml(paras: string[], embed?: string): string { - const body = paras - .map(p => `${p}`) - .join(''); - const pic = embed ? `` : ''; - return `${body}${pic}`; +const NS = 'xmlns:a="urn:a" xmlns:r="urn:r" xmlns:p="urn:p"'; + +function textSp( + geo: { x: number; y: number; cx: number; cy: number } | null, + text: string, + opts: { sz?: number; b?: boolean; color?: string; align?: string; ph?: string } = {}, +): string { + const xfrm = geo + ? `` + : ''; + const ph = opts.ph ? `` : ''; + const rPr = ``; + return `${ph}${xfrm}${rPr}${text}`; } -describe('parsePptx', () => { - it('reads ordered slide text', () => { +const slide = (inner: string) => `${inner}`; +const PRES = ``; + +describe('parsePptx (positioned)', () => { + it('reads slide size and shape geometry in px', () => { + const bytes = zipSync({ + 'ppt/presentation.xml': strToU8(PRES), + 'ppt/slides/slide1.xml': strToU8( + slide(textSp({ x: 914400, y: 457200, cx: 1828800, cy: 914400 }, 'Hello', { sz: 1800, b: true, color: 'C00000', align: 'ctr' })), + ), + }); + const doc = parsePptx(bytes); + expect(doc.widthPx).toBe(960); + expect(doc.heightPx).toBe(720); + expect(doc.slides).toHaveLength(1); + const shape = doc.slides[0].shapes[0]; + expect(shape).toMatchObject({ kind: 'text', x: 96, y: 48, w: 192, h: 96 }); + expect(shape.paragraphs![0].align).toBe('center'); + const run = shape.paragraphs![0].runs[0]; + expect(run).toEqual({ text: 'Hello', bold: true, italic: false, sizePt: 18, color: '#C00000' }); + }); + + it('resolves inherited placeholder geometry from the layout', () => { const bytes = zipSync({ - 'ppt/slides/slide1.xml': strToU8(slideXml(['Hello', 'World'])), - 'ppt/slides/slide2.xml': strToU8(slideXml(['Second slide'])), + 'ppt/slides/slide1.xml': strToU8(slide(textSp(null, 'Title text', { ph: 'title' }))), + 'ppt/slides/_rels/slide1.xml.rels': strToU8( + ``, + ), + 'ppt/slideLayouts/slideLayout1.xml': strToU8( + slide(textSp({ x: 100000, y: 200000, cx: 300000, cy: 400000 }, '', { ph: 'title' })), + ), }); const doc = parsePptx(bytes); - expect(doc.slides).toHaveLength(2); - expect(doc.slides[0].paragraphs).toEqual(['Hello', 'World']); - expect(doc.slides[1].paragraphs).toEqual(['Second slide']); + const shape = doc.slides[0].shapes[0]; + expect(shape.x).toBe(emu(100000)); + expect(shape.paragraphs![0].runs[0].text).toBe('Title text'); }); - it('resolves an embedded image via slide rels', () => { + it('places an embedded image via rels', () => { const bytes = zipSync({ - 'ppt/slides/slide1.xml': strToU8(slideXml(['With image'], 'rId2')), + 'ppt/slides/slide1.xml': strToU8( + slide(``), + ), 'ppt/slides/_rels/slide1.xml.rels': strToU8( - ``, + ``, ), 'ppt/media/image1.png': new Uint8Array([1, 2, 3]), }); const doc = parsePptx(bytes); - expect(doc.slides[0].images).toEqual(['ppt/media/image1.png']); - expect(doc.media['ppt/media/image1.png']).toBeDefined(); + const img = doc.slides[0].shapes.find(s => s.kind === 'image'); + expect(img).toMatchObject({ kind: 'image', imageKey: 'ppt/media/image1.png', w: 96, h: 96 }); }); - it('orders slides numerically, not lexically', () => { + it('orders slides numerically', () => { const bytes = zipSync({ - 'ppt/slides/slide2.xml': strToU8(slideXml(['two'])), - 'ppt/slides/slide10.xml': strToU8(slideXml(['ten'])), - 'ppt/slides/slide1.xml': strToU8(slideXml(['one'])), + 'ppt/slides/slide2.xml': strToU8(slide(textSp({ x: 0, y: 0, cx: 100, cy: 100 }, 'two'))), + 'ppt/slides/slide10.xml': strToU8(slide(textSp({ x: 0, y: 0, cx: 100, cy: 100 }, 'ten'))), + 'ppt/slides/slide1.xml': strToU8(slide(textSp({ x: 0, y: 0, cx: 100, cy: 100 }, 'one'))), }); const doc = parsePptx(bytes); - expect(doc.slides.map(s => s.paragraphs[0])).toEqual(['one', 'two', 'ten']); + expect(doc.slides.map(s => s.shapes[0].paragraphs![0].runs[0].text)).toEqual(['one', 'two', 'ten']); }); }); + +function emu(v: number): number { + return Math.round(v / 9525); +} diff --git a/src/tools/documents/pptx.lib.ts b/src/tools/documents/pptx.lib.ts index 8f9134b..59adcc3 100644 --- a/src/tools/documents/pptx.lib.ts +++ b/src/tools/documents/pptx.lib.ts @@ -1,27 +1,58 @@ /** - * Minimal, dependency-light PPTX (OOXML) reader — pulls slide text and image - * references out of a .pptx zip. It is a lightweight viewer, not a - * pixel-perfect renderer: it surfaces each slide's text paragraphs (in order) - * and its embedded images, which covers the common "what's in these slides" - * need without a heavyweight rendering engine. + * Dependency-light PPTX (OOXML) reader that recovers each slide's *layout*: + * every shape's position and size (EMU → px), its styled text runs, and its + * images — so the viewer can render slides at their real geometry rather than + * a flat text dump. It resolves inherited placeholder geometry from the slide + * layout. It is still a lightweight reader (no masters chain, charts, SmartArt + * or effects), not a full rendering engine. */ import { unzipSync, strFromU8 } from 'fflate'; +/** 914400 EMU per inch ÷ 96 px per inch. */ +const EMU_PER_PX = 9525; +const DEFAULT_W_EMU = 9144000; // 10in (4:3) +const DEFAULT_H_EMU = 6858000; // 7.5in + +export interface TextRun { + text: string; + bold: boolean; + italic: boolean; + sizePt: number | null; + color: string | null; // #RRGGBB +} + +export type TextAlign = 'left' | 'center' | 'right' | 'justify'; + +export interface Paragraph { + runs: TextRun[]; + align: TextAlign; +} + +export interface Shape { + kind: 'text' | 'image'; + x: number; + y: number; + w: number; + h: number; + paragraphs?: Paragraph[]; + imageKey?: string; +} + export interface PptxSlide { - /** Text paragraphs on the slide, in document order. */ - paragraphs: string[]; - /** Media keys (into `PptxDoc.media`) for images on the slide. */ - images: string[]; + shapes: Shape[]; } export interface PptxDoc { + widthPx: number; + heightPx: number; slides: PptxSlide[]; - /** Raw bytes of every ppt/media/* entry, keyed by path. */ media: Record; } -function slideNumber(name: string): number { - return Number(name.match(/slide(\d+)\.xml$/)?.[1] ?? 0); +interface Geo { x: number; y: number; w: number; h: number } + +function emuToPx(emu: number): number { + return Math.round(emu / EMU_PER_PX); } function decodeXml(s: string): string { @@ -35,19 +66,49 @@ function decodeXml(s: string): string { .replace(/&/g, '&'); } -/** - * Pull each paragraph's text out of a slide XML. Uses regex rather than - * DOMParser so it behaves identically in the browser and under test, and - * needs no DOM. OOXML nests `` text runs inside `` paragraphs. - */ -function extractParagraphs(xml: string): string[] { - const out: string[] = []; - for (const pm of xml.matchAll(/<(?:[a-zA-Z]+:)?p\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?p>/g)) { - const runs = Array.from(pm[1].matchAll(/<(?:[a-zA-Z]+:)?t\b[^>]*>([\s\S]*?)<\/(?:[a-zA-Z]+:)?t>/g)); - const text = runs.map(m => decodeXml(m[1])).join(''); - if (text.trim()) out.push(text); +function slideNumber(name: string): number { + return Number(name.match(/slide(\d+)\.xml$/)?.[1] ?? 0); +} + +/** Extract the a:off/a:ext transform from a shape block, in px. */ +function parseXfrm(block: string): Geo | null { + const off = block.match(/]*>/)?.[0]; + const ext = block.match(/]*>/)?.[0]; + if (!off || !ext) return null; + const x = off.match(/\bx="(-?\d+)"/)?.[1]; + const y = off.match(/\by="(-?\d+)"/)?.[1]; + const cx = ext.match(/\bcx="(\d+)"/)?.[1]; + const cy = ext.match(/\bcy="(\d+)"/)?.[1]; + if (x == null || y == null || cx == null || cy == null) return null; + return { x: emuToPx(+x), y: emuToPx(+y), w: emuToPx(+cx), h: emuToPx(+cy) }; +} + +const ALIGN: Record = { l: 'left', ctr: 'center', r: 'right', just: 'justify' }; + +function parseParagraphs(txBody: string): Paragraph[] { + const paras: Paragraph[] = []; + for (const pm of txBody.matchAll(/]*>([\s\S]*?)<\/a:p>/g)) { + const body = pm[1]; + const align = ALIGN[body.match(/]*\balgn="(\w+)"/)?.[1] ?? ''] ?? 'left'; + const runs: TextRun[] = []; + for (const rm of body.matchAll(/]*>([\s\S]*?)<\/a:r>/g)) { + const run = rm[1]; + const t = run.match(/]*>([\s\S]*?)<\/a:t>/); + if (!t) continue; + const rPr = run.match(/]*>/)?.[0] ?? ''; + const sz = rPr.match(/\bsz="(\d+)"/)?.[1]; + const color = run.match(/\s* r.text.trim())) paras.push({ runs, align }); } - return out; + return paras; } function parseRels(xml: string): Record { @@ -60,7 +121,6 @@ function parseRels(xml: string): Record { return map; } -/** Resolve a slide-relative rels target (e.g. "../media/image1.png") to a zip key. */ function normalizeMedia(target: string): string { const cleaned = target.replace(/^\.\//, ''); if (cleaned.startsWith('../')) return `ppt/${cleaned.slice(3)}`; @@ -68,34 +128,102 @@ function normalizeMedia(target: string): string { return `ppt/slides/${cleaned}`; } -/** Parse a .pptx file's bytes into ordered slides + media. */ +function placeholderKey(block: string): string | null { + const ph = block.match(/]*>/)?.[0] ?? block.match(/]*\/>/)?.[0]; + if (ph === undefined) return null; + const tag = ph ?? ''; + const type = tag.match(/\btype="([^"]+)"/)?.[1] ?? 'body'; + const idx = tag.match(/\bidx="([^"]+)"/)?.[1] ?? ''; + return `${type}|${idx}`; +} + +/** Build a placeholder-geometry map (key → px geo) from a slide layout XML. */ +function layoutPlaceholders(xml: string): Record { + const map: Record = {}; + for (const sm of xml.matchAll(/]*>([\s\S]*?)<\/p:sp>/g)) { + const block = sm[1]; + const geo = parseXfrm(block); + const key = placeholderKey(block); + if (geo && key) { + map[key] = geo; + const [type, idx] = key.split('|'); + map[`${type}|`] ??= geo; + map[`|${idx}`] ??= geo; + } + } + return map; +} + +function resolvePlaceholderGeo(block: string, layout: Record): Geo | null { + const key = placeholderKey(block); + if (!key) return null; + const [type, idx] = key.split('|'); + return layout[key] ?? layout[`${type}|`] ?? layout[`|${idx}`] ?? null; +} + +function parseSlide( + xml: string, + rels: Record, + layout: Record, + media: Record, +): PptxSlide { + const shapes: Shape[] = []; + + for (const sm of xml.matchAll(/]*>([\s\S]*?)<\/p:sp>/g)) { + const block = sm[1]; + const txBody = block.match(/([\s\S]*?)<\/p:txBody>/); + const paragraphs = txBody ? parseParagraphs(txBody[1]) : []; + if (paragraphs.length === 0) continue; + const geo = parseXfrm(block) ?? resolvePlaceholderGeo(block, layout); + if (!geo) continue; + shapes.push({ kind: 'text', ...geo, paragraphs }); + } + + for (const pm of xml.matchAll(/]*>([\s\S]*?)<\/p:pic>/g)) { + const block = pm[1]; + const geo = parseXfrm(block); + const embed = block.match(/r:embed="([^"]+)"/)?.[1]; + if (!geo || !embed) continue; + const target = rels[embed]; + if (!target) continue; + const key = normalizeMedia(target); + if (key in media) shapes.push({ kind: 'image', ...geo, imageKey: key }); + } + + return { shapes }; +} + +/** Parse a .pptx file's bytes into positioned slides + media. */ export function parsePptx(bytes: Uint8Array): PptxDoc { const files = unzipSync(bytes); + const read = (name: string) => (files[name] ? strFromU8(files[name]) : ''); const media: Record = {}; for (const [name, data] of Object.entries(files)) { if (name.startsWith('ppt/media/')) media[name] = data; } + const pres = read('ppt/presentation.xml'); + const sldSz = pres.match(/]*>/)?.[0] ?? ''; + const widthPx = emuToPx(Number(sldSz.match(/\bcx="(\d+)"/)?.[1] ?? DEFAULT_W_EMU)); + const heightPx = emuToPx(Number(sldSz.match(/\bcy="(\d+)"/)?.[1] ?? DEFAULT_H_EMU)); + const slideNames = Object.keys(files) .filter(n => /^ppt\/slides\/slide\d+\.xml$/.test(n)) .sort((a, b) => slideNumber(a) - slideNumber(b)); - const slides: PptxSlide[] = slideNames.map(name => { - const xml = strFromU8(files[name]); - const paragraphs = extractParagraphs(xml); + const slides = slideNames.map(name => { + const xml = read(name); + const relsXml = read(name.replace(/^ppt\/slides\/(slide\d+)\.xml$/, 'ppt/slides/_rels/$1.xml.rels')); + const rels = relsXml ? parseRels(relsXml) : {}; - const relsName = name.replace(/^ppt\/slides\/(slide\d+)\.xml$/, 'ppt/slides/_rels/$1.xml.rels'); - const rels = files[relsName] ? parseRels(strFromU8(files[relsName])) : {}; - const embeds = Array.from(xml.matchAll(/r:embed="([^"]+)"/g)).map(m => m[1]); - const images = embeds - .map(id => rels[id]) - .filter((tgt): tgt is string => Boolean(tgt)) - .map(normalizeMedia) - .filter(key => key in media); + // Resolve the slide's layout for inherited placeholder geometry. + const layoutTarget = Object.entries(rels).find(([, t]) => t.includes('slideLayout'))?.[1]; + const layoutKey = layoutTarget ? normalizeMedia(layoutTarget) : ''; + const layout = layoutKey && files[layoutKey] ? layoutPlaceholders(read(layoutKey)) : {}; - return { paragraphs, images }; + return parseSlide(xml, rels, layout, media); }); - return { slides, media }; + return { widthPx, heightPx, slides, media }; }