From 5f0617505b4eb8afc0dcf9d66f49e55831d7e257 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:12:17 +0700 Subject: [PATCH 1/2] feat(dev): add IPv6 support to the CIDR calculator Parse IPv6 addresses/prefixes with BigInt: network, first/last address, total addresses, plus RFC 5952 compression and full-form expansion. The calculator now auto-detects IPv4 vs IPv6 by address family. --- src/islands/dev/CidrCalculator.tsx | 72 +++++++++++-------- src/tools/dev/cidr.lib.test.ts | 50 ++++++++++++- src/tools/dev/cidr.lib.ts | 110 +++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 32 deletions(-) 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/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); +} From 3c12186238751347f4809258562d62feaf1ab1a7 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:12:17 +0700 Subject: [PATCH 2/2] feat(documents): render PPTX slides with real layout Rewrite the PPTX reader to recover each shape's position and size (EMU to px), styled text runs (size, bold, italic, color, alignment) and images, resolving inherited placeholder geometry from the slide layout. The viewer now draws slides at their actual positions instead of a flat text list. --- src/islands/documents/PptxViewer.tsx | 93 ++++++++---- src/registry/tool-seo.ts | 4 +- src/tools/documents/pptx.lib.test.ts | 87 +++++++---- src/tools/documents/pptx.lib.ts | 206 ++++++++++++++++++++++----- 4 files changed, 300 insertions(+), 90 deletions(-) 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/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 }; }