···7788## [Unreleased]
991010+## [0.36.0] — 2026-04-13
1111+1212+### Added
1313+- PDF import: drop or import a `.pdf` file from the landing page or docs in-editor import menu — text is extracted page-by-page via pdf.js (dynamically loaded) and converted to headings and paragraphs in the TipTap editor (#608)
1414+- PPTX import: drop or import a `.pptx` file from the landing page — slides are parsed from the ZIP+XML format using JSZip (no new deps), mapped to our canvas element model with title, body, and other text shapes; speaker notes are preserved (#608)
1515+1016## [0.35.0] — 2026-04-13
11171218### Added
1919+- Add calendar event reminders and notifications (#587)
1320- Calendar: Web Push reminders now sync to the server scheduler so notifications fire even when the browser tab is closed — all events with reminders within the next 30 days are pushed to `/api/push/schedule` on load and after every save (#588, #590)
1421- Calendar: persistent notifications toggle in the Settings popover — users who dismissed the one-time banner can enable or disable push reminders at any time (#594)
1522- Calendar: external ICS subscription events now appear in week view and day view (timed blocks and all-day bar), consistent with the existing month and agenda view overlays (#595)
···11+/**
22+ * Tests for PDF import validation and HTML conversion helpers.
33+ *
44+ * pdf.js requires browser APIs so convertPdfToHtml is not tested here —
55+ * only the pure isValidPdf validator is covered, which is DOM-free.
66+ */
77+import { describe, it, expect } from 'vitest';
88+import { isValidPdf } from '../src/docs/pdf-import.js';
99+1010+function makeBuffer(bytes: number[]): ArrayBuffer {
1111+ return new Uint8Array(bytes).buffer;
1212+}
1313+1414+describe('isValidPdf', () => {
1515+ it('returns true for a buffer starting with %PDF-', () => {
1616+ // %PDF- = 0x25 0x50 0x44 0x46 0x2D
1717+ const buf = makeBuffer([0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x34]);
1818+ expect(isValidPdf(buf)).toBe(true);
1919+ });
2020+2121+ it('returns false for a buffer that does not start with %PDF-', () => {
2222+ const buf = makeBuffer([0x50, 0x4B, 0x03, 0x04]); // PK (ZIP)
2323+ expect(isValidPdf(buf)).toBe(false);
2424+ });
2525+2626+ it('returns false for an empty buffer', () => {
2727+ expect(isValidPdf(new ArrayBuffer(0))).toBe(false);
2828+ });
2929+3030+ it('returns false for a buffer shorter than 5 bytes', () => {
3131+ expect(isValidPdf(makeBuffer([0x25, 0x50, 0x44]))).toBe(false);
3232+ });
3333+3434+ it('returns false for a buffer with correct first byte but wrong rest', () => {
3535+ expect(isValidPdf(makeBuffer([0x25, 0x00, 0x00, 0x00, 0x00]))).toBe(false);
3636+ });
3737+3838+ it('returns false for null/undefined-like (non-ArrayBuffer) via type safety', () => {
3939+ // TypeScript won't allow passing null, but check the guard still works for
4040+ // runtime safety (cast to any for the test).
4141+ expect(isValidPdf(null as unknown as ArrayBuffer)).toBe(false);
4242+ expect(isValidPdf(undefined as unknown as ArrayBuffer)).toBe(false);
4343+ });
4444+});
+217
tests/pptx-import.test.ts
···11+/**
22+ * Tests for PPTX import — isValidPptx and convertPptxToDeck.
33+ *
44+ * convertPptxToDeck uses DOMParser (available in jsdom) and JSZip,
55+ * so we can test it end-to-end with minimal synthetic PPTX buffers.
66+ */
77+88+// @vitest-environment jsdom
99+1010+import { describe, it, expect } from 'vitest';
1111+import JSZip from 'jszip';
1212+import { isValidPptx, convertPptxToDeck } from '../src/slides/pptx-import.js';
1313+1414+function makeBuffer(bytes: number[]): ArrayBuffer {
1515+ return new Uint8Array(bytes).buffer;
1616+}
1717+1818+// ---------------------------------------------------------------------------
1919+// isValidPptx
2020+// ---------------------------------------------------------------------------
2121+2222+describe('isValidPptx', () => {
2323+ it('returns true for a ZIP-signature buffer', () => {
2424+ // PK\x03\x04
2525+ expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03, 0x04, 0x00]))).toBe(true);
2626+ });
2727+2828+ it('returns false for a non-ZIP buffer', () => {
2929+ expect(isValidPptx(makeBuffer([0x25, 0x50, 0x44, 0x46, 0x2D]))).toBe(false); // PDF
3030+ });
3131+3232+ it('returns false for an empty buffer', () => {
3333+ expect(isValidPptx(new ArrayBuffer(0))).toBe(false);
3434+ });
3535+3636+ it('returns false for a buffer shorter than 4 bytes', () => {
3737+ expect(isValidPptx(makeBuffer([0x50, 0x4B, 0x03]))).toBe(false);
3838+ });
3939+4040+ it('returns false for null/undefined', () => {
4141+ expect(isValidPptx(null as unknown as ArrayBuffer)).toBe(false);
4242+ expect(isValidPptx(undefined as unknown as ArrayBuffer)).toBe(false);
4343+ });
4444+});
4545+4646+// ---------------------------------------------------------------------------
4747+// Helpers to build minimal synthetic PPTX ZIPs
4848+// ---------------------------------------------------------------------------
4949+5050+/** Minimal slide XML with one title shape and one body shape. */
5151+function makeSlideXml(title: string, body: string): string {
5252+ return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
5353+<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
5454+ xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
5555+ xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
5656+ <p:cSld>
5757+ <p:spTree>
5858+ <p:sp>
5959+ <p:nvSpPr>
6060+ <p:ph type="title"/>
6161+ </p:nvSpPr>
6262+ <p:spPr>
6363+ <a:xfrm>
6464+ <a:off x="457200" y="274638"/>
6565+ <a:ext cx="8229600" cy="1143000"/>
6666+ </a:xfrm>
6767+ </p:spPr>
6868+ <p:txBody>
6969+ <a:p><a:r><a:rPr sz="4400" b="1"/><a:t>${title}</a:t></a:r></a:p>
7070+ </p:txBody>
7171+ </p:sp>
7272+ <p:sp>
7373+ <p:nvSpPr>
7474+ <p:ph type="body"/>
7575+ </p:nvSpPr>
7676+ <p:spPr>
7777+ <a:xfrm>
7878+ <a:off x="457200" y="1600200"/>
7979+ <a:ext cx="8229600" cy="3700620"/>
8080+ </a:xfrm>
8181+ </p:spPr>
8282+ <p:txBody>
8383+ <a:p><a:r><a:rPr sz="2400"/><a:t>${body}</a:t></a:r></a:p>
8484+ </p:txBody>
8585+ </p:sp>
8686+ </p:spTree>
8787+ </p:cSld>
8888+</p:sld>`;
8989+}
9090+9191+/** Build a minimal PPTX zip with the given slide XMLs (no presentation.xml, uses file order). */
9292+async function buildMinimalPptx(slides: string[]): Promise<ArrayBuffer> {
9393+ const zip = new JSZip();
9494+ for (let i = 0; i < slides.length; i++) {
9595+ zip.file(`ppt/slides/slide${i + 1}.xml`, slides[i]!);
9696+ }
9797+ return zip.generateAsync({ type: 'arraybuffer' });
9898+}
9999+100100+// ---------------------------------------------------------------------------
101101+// convertPptxToDeck
102102+// ---------------------------------------------------------------------------
103103+104104+describe('convertPptxToDeck', () => {
105105+ it('produces a deck with one slide for a single-slide PPTX', async () => {
106106+ const buf = await buildMinimalPptx([makeSlideXml('Hello World', 'Slide content')]);
107107+ const deck = await convertPptxToDeck(buf);
108108+ expect(deck.slides).toHaveLength(1);
109109+ expect(deck.currentSlide).toBe(0);
110110+ expect(deck.aspectRatio).toBeCloseTo(16 / 9, 2);
111111+ });
112112+113113+ it('extracts title text into a slide element', async () => {
114114+ const buf = await buildMinimalPptx([makeSlideXml('My Title', 'Body text here')]);
115115+ const deck = await convertPptxToDeck(buf);
116116+ const slide = deck.slides[0]!;
117117+ const titleEl = slide.elements.find(e => e.content === 'My Title');
118118+ expect(titleEl).toBeDefined();
119119+ expect(titleEl!.type).toBe('text');
120120+ });
121121+122122+ it('extracts body text into a slide element', async () => {
123123+ const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body text here')]);
124124+ const deck = await convertPptxToDeck(buf);
125125+ const slide = deck.slides[0]!;
126126+ const bodyEl = slide.elements.find(e => e.content === 'Body text here');
127127+ expect(bodyEl).toBeDefined();
128128+ });
129129+130130+ it('handles multiple slides', async () => {
131131+ const buf = await buildMinimalPptx([
132132+ makeSlideXml('Slide One', 'First slide content'),
133133+ makeSlideXml('Slide Two', 'Second slide content'),
134134+ makeSlideXml('Slide Three', 'Third slide content'),
135135+ ]);
136136+ const deck = await convertPptxToDeck(buf);
137137+ expect(deck.slides).toHaveLength(3);
138138+ });
139139+140140+ it('maps EMU coordinates to pixel positions within canvas bounds', async () => {
141141+ const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]);
142142+ const deck = await convertPptxToDeck(buf);
143143+ const slide = deck.slides[0]!;
144144+ for (const el of slide.elements) {
145145+ expect(el.x).toBeGreaterThanOrEqual(0);
146146+ expect(el.y).toBeGreaterThanOrEqual(0);
147147+ expect(el.width).toBeGreaterThan(0);
148148+ expect(el.height).toBeGreaterThan(0);
149149+ }
150150+ });
151151+152152+ it('returns a single blank slide for an empty PPTX (no slides)', async () => {
153153+ const zip = new JSZip();
154154+ zip.file('ppt/placeholder.txt', 'empty');
155155+ const buf = await zip.generateAsync({ type: 'arraybuffer' });
156156+ const deck = await convertPptxToDeck(buf);
157157+ expect(deck.slides).toHaveLength(1);
158158+ expect(deck.slides[0]!.elements).toHaveLength(0);
159159+ });
160160+161161+ it('skips shapes with no text content', async () => {
162162+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
163163+<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
164164+ xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
165165+ <p:cSld>
166166+ <p:spTree>
167167+ <p:sp>
168168+ <p:nvSpPr><p:ph type="title"/></p:nvSpPr>
169169+ <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr>
170170+ <p:txBody><a:p><a:r><a:t> </a:t></a:r></a:p></p:txBody>
171171+ </p:sp>
172172+ </p:spTree>
173173+ </p:cSld>
174174+</p:sld>`;
175175+ const buf = await buildMinimalPptx([xml]);
176176+ const deck = await convertPptxToDeck(buf);
177177+ // Whitespace-only shape should be skipped
178178+ expect(deck.slides[0]!.elements).toHaveLength(0);
179179+ });
180180+181181+ it('applies title font weight bold', async () => {
182182+ const buf = await buildMinimalPptx([makeSlideXml('Bold Title', 'content')]);
183183+ const deck = await convertPptxToDeck(buf);
184184+ const titleEl = deck.slides[0]!.elements.find(e => e.content === 'Bold Title');
185185+ expect(titleEl?.style['fontWeight']).toBe('bold');
186186+ });
187187+188188+ it('extracts background color from solidFill', async () => {
189189+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
190190+<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
191191+ xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
192192+ <p:cSld>
193193+ <p:bg>
194194+ <p:bgPr>
195195+ <a:solidFill><a:srgbClr val="1a2b3c"/></a:solidFill>
196196+ </p:bgPr>
197197+ </p:bg>
198198+ <p:spTree>
199199+ <p:sp>
200200+ <p:nvSpPr><p:ph type="title"/></p:nvSpPr>
201201+ <p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="9144000" cy="1000000"/></a:xfrm></p:spPr>
202202+ <p:txBody><a:p><a:r><a:t>Title</a:t></a:r></a:p></p:txBody>
203203+ </p:sp>
204204+ </p:spTree>
205205+ </p:cSld>
206206+</p:sld>`;
207207+ const buf = await buildMinimalPptx([xml]);
208208+ const deck = await convertPptxToDeck(buf);
209209+ expect(deck.slides[0]!.background).toBe('#1a2b3c');
210210+ });
211211+212212+ it('defaults to white background when none specified', async () => {
213213+ const buf = await buildMinimalPptx([makeSlideXml('Title', 'Body')]);
214214+ const deck = await convertPptxToDeck(buf);
215215+ expect(deck.slides[0]!.background).toBe('#ffffff');
216216+ });
217217+});