Skip to content

Commit ebf2a5a

Browse files
authored
Merge pull request #113 from cloakyard/feature/redesign
Editor: native Markdown export + rotation fixes + preview regression tests
2 parents 09a64c2 + 3e80a31 commit ebf2a5a

9 files changed

Lines changed: 562 additions & 86 deletions

File tree

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
"@langchain/community": "^1.1.29",
2020
"@langchain/core": "^1.2.0",
2121
"@langchain/langgraph": "^1.4.4",
22-
"@llamaindex/liteparse-wasm": "^2.1.0",
23-
"@pdfme/pdf-lib": "^6.1.6",
22+
"@llamaindex/liteparse-wasm": "^2.1.1",
23+
"@pdfme/pdf-lib": "^6.1.8",
2424
"jszip": "^3.10.1",
2525
"lucide-react": "^1.21.0",
2626
"motion": "^12.40.0",

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/editor/ExportModal.tsx

Lines changed: 36 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {
2424
FileCode2,
2525
FileText,
2626
FileX2,
27-
Hash,
2827
Image as ImageIcon,
2928
Layers,
3029
type LucideIcon,
@@ -39,7 +38,7 @@ import { useFocusTrap } from "../utils/useFocusTrap";
3938
import { createPortal } from "react-dom";
4039
import { AnimatePresence, m, variants } from "../components/motion.tsx";
4140
import { downloadBlob, downloadPdf, pdfFilename } from "../utils/file-helpers.ts";
42-
import { extractLayout, layoutToMarkdown, layoutToPlainText } from "../utils/layout-extract.ts";
41+
import { extractLayout, extractMarkdown, layoutToPlainText } from "../utils/layout-extract.ts";
4342
import {
4443
compressPdf,
4544
flattenPdf,
@@ -51,7 +50,8 @@ import {
5150
} from "../utils/pdf-operations.ts";
5251
import { renderPagesToBlobs } from "../utils/pdf-renderer.ts";
5352
import { flattenDestructiveObjects, hasPendingDestructive } from "./doc.ts";
54-
import { useEditorActions, useEditorRead } from "./EditorContext.tsx";
53+
import { useEditorActions, useEditorRead, useToolSlice } from "./EditorContext.tsx";
54+
import { hasPendingPageChanges, ORGANIZE_ID } from "./panels/OrganizeTool.tsx";
5555
import { Segmented } from "./panels/WholeDocPanel.tsx";
5656

5757
const IMAGE_DPI = 150;
@@ -75,7 +75,7 @@ const FORMATS: { value: Format; icon: LucideIcon; label: string; hint: string }[
7575
value: "markdown",
7676
icon: FileCode2,
7777
label: "Markdown (.md)",
78-
hint: "Headings + text, on-device",
78+
hint: "Headings, lists & links",
7979
},
8080
];
8181

@@ -233,8 +233,6 @@ export function ExportButton() {
233233
const [flatten, setFlatten] = useState(false);
234234
const [repair, setRepair] = useState(false);
235235
const [stripMeta, setStripMeta] = useState(false);
236-
// Markdown export: infer headings from font-size bands (off → plain paragraphs).
237-
const [mdHeadings, setMdHeadings] = useState(true);
238236

239237
const busy = busyLabel !== null;
240238
const closeBtnRef = useRef<HTMLButtonElement>(null);
@@ -281,6 +279,11 @@ export function ExportButton() {
281279
const pendingMarks = doc
282280
? doc.objects.filter((o) => o.kind === "redaction" || o.kind === "erase").length
283281
: 0;
282+
// Unapplied Organize changes (rotate / reorder / delete) live in tool state,
283+
// not the bytes — export builds from the committed bytes, so without an "Apply
284+
// changes" they'd be silently dropped from the download. Warn so they aren't.
285+
const organizeSlice = useToolSlice(ORGANIZE_ID);
286+
const pendingPageChanges = hasPendingPageChanges(organizeSlice);
284287

285288
// The document with every destructive mark burned in, wrapped as a File for
286289
// the writers. The single source of bytes for every export format.
@@ -385,24 +388,26 @@ export function ExportButton() {
385388
return;
386389
}
387390

388-
// Text / Markdown — reconstruct reading-order text on-device (liteparse +
389-
// Tesseract for scanned pages), then serialise. The wasm + OCR engine stay
390-
// lazy inside extractLayout, so importing it costs nothing until used.
391-
// Extracts from the FLATTENED bytes so any pending redaction is gone first.
391+
// Text / Markdown — reconstruct the document on-device, then serialise.
392+
// Markdown uses liteparse's native renderer (real heading levels, lists,
393+
// [text](url) links); text uses the column-aware reading-order reflow. Both
394+
// OCR scanned pages with Tesseract. The wasm + OCR engine stay lazy inside
395+
// the extractors, so importing them costs nothing until used. Extracts from
396+
// the FLATTENED bytes so any pending redaction is gone first.
392397
if (format === "text" || format === "markdown") {
393398
const isMd = format === "markdown";
394399
void runTask(isMd ? "Building Markdown…" : "Extracting text…", async (setLabel) => {
395400
// Scanned pages run on-device OCR (one-time engine download) which can
396401
// take many seconds; surface determinate progress in the overlay so a
397402
// long extraction doesn't read as a hang. Wording matches OcrTool so the
398403
// two surfaces read identically. Digital PDFs skip OCR and keep the
399-
// static "Extracting text…" label.
400-
const pages = await extractLayout(await flattenedFile(), {
401-
onOcrPage: (done, total) => setLabel(`Recognising page ${done} / ${total}…`),
402-
});
404+
// static "Building Markdown…" / "Extracting text…" label.
405+
const onOcrPage = (done: number, total: number) =>
406+
setLabel(`Recognising page ${done} / ${total}…`);
407+
const file = await flattenedFile();
403408
const content = isMd
404-
? layoutToMarkdown(pages, { headings: mdHeadings })
405-
: layoutToPlainText(pages);
409+
? await extractMarkdown(file, { onOcrPage })
410+
: layoutToPlainText(await extractLayout(file, { onOcrPage }));
406411
downloadBlob(
407412
new Blob([content], {
408413
type: isMd ? "text/markdown;charset=utf-8" : "text/plain;charset=utf-8",
@@ -437,7 +442,6 @@ export function ExportButton() {
437442
flatten,
438443
repair,
439444
stripMeta,
440-
mdHeadings,
441445
baseName,
442446
runTask,
443447
buildPdf,
@@ -522,6 +526,16 @@ export function ExportButton() {
522526
</span>
523527
</div>
524528
)}
529+
{pendingPageChanges && (
530+
<div className="flex items-start gap-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-900/15 dark:text-amber-300">
531+
<Layers className="mt-0.5 h-3.5 w-3.5 shrink-0" />
532+
<span>
533+
You have unapplied page changes (rotate / reorder / delete). Close this and
534+
hit <strong>Apply changes</strong> in Organize first, or they won't be in
535+
the download.
536+
</span>
537+
</div>
538+
)}
525539
<div className="flex flex-col gap-2">
526540
<SectionLabel>Format</SectionLabel>
527541
<div
@@ -570,24 +584,14 @@ export function ExportButton() {
570584

571585
{isText && (
572586
<p className="-mt-1 px-0.5 text-xs text-slate-500 dark:text-dark-text-muted">
573-
Reading order is reconstructed on-device. Scanned pages are read with OCR
574-
(one-time engine download) — nothing leaves your browser.
587+
{format === "markdown"
588+
? "Headings, lists, and links are detected on-device. "
589+
: "Reading order is reconstructed on-device. "}
590+
Scanned pages are read with OCR (one-time engine download) — nothing leaves
591+
your browser.
575592
</p>
576593
)}
577594

578-
{format === "markdown" && (
579-
<div className="flex flex-col gap-2">
580-
<SectionLabel>Markdown</SectionLabel>
581-
<OptionRow
582-
icon={Hash}
583-
label="Infer headings"
584-
hint="Use font sizes to add #, ##, ### headings"
585-
checked={mdHeadings}
586-
onChange={setMdHeadings}
587-
/>
588-
</div>
589-
)}
590-
591595
{isPdf && (
592596
<div className="flex flex-col gap-2">
593597
<SectionLabel>Options</SectionLabel>

src/editor/panels/OrganizeTool.tsx

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ import { PrimaryAction } from "./PrimaryAction.tsx";
2323

2424
export const ORGANIZE_ID = "organize-pages";
2525

26+
/**
27+
* True when the Organize slice holds page changes (reorder / rotate / delete)
28+
* not yet baked into `doc.bytes` — i.e. "Apply changes" hasn't been pressed.
29+
* The Export modal reads this to warn that a download would otherwise silently
30+
* drop them (export builds from the committed bytes, not the pending plan).
31+
*/
32+
export function hasPendingPageChanges(slice: Record<string, unknown>): boolean {
33+
const order = slice.order as number[] | undefined;
34+
const rotations = (slice.rotations as Record<number, number> | undefined) ?? {};
35+
const deleted = (slice.deleted as number[] | undefined) ?? [];
36+
const reordered = !!order && order.some((v, i) => v !== i);
37+
const rotated = Object.values(rotations).some((d) => d % 360 !== 0);
38+
return reordered || rotated || deleted.length > 0;
39+
}
40+
2641
// Fraction of near-white pixels above which a page is treated as blank. High so
2742
// a faint header/footer isn't swept up. (Absorbed from the old Remove-blank.)
2843
const BLANK_THRESHOLD = 0.995;
@@ -325,11 +340,12 @@ export function Panel() {
325340
}, [patchToolState, pageCount]);
326341

327342
const apply = useCallback(() => {
343+
const order =
344+
(slice.order as number[] | undefined) ?? Array.from({ length: pageCount }, (_, i) => i);
345+
const deleted = (slice.deleted as number[] | undefined) ?? [];
346+
const rotations = (slice.rotations as Record<number, number> | undefined) ?? {};
347+
const survivors = order.filter((i) => !deleted.includes(i));
328348
void applyTransform(async (d) => {
329-
const order = (slice.order as number[] | undefined) ?? d.pages.map((p) => p.index);
330-
const deleted = (slice.deleted as number[] | undefined) ?? [];
331-
const rotations = (slice.rotations as Record<number, number> | undefined) ?? {};
332-
const survivors = order.filter((i) => !deleted.includes(i));
333349
const ops: AssembleOp[] = survivors.map((i) => ({
334350
kind: "page",
335351
sourceIndex: 0,
@@ -345,8 +361,22 @@ export function Panel() {
345361
.filter((o) => newIndex.has(o.pageIndex) && (rotations[o.pageIndex] ?? 0) % 360 === 0)
346362
.map((o) => ({ ...o, pageIndex: newIndex.get(o.pageIndex)! }));
347363
return { bytes, label: "Organize pages", objects };
364+
}).then(() => {
365+
// The plan is now baked into the rebuilt doc's bytes. Reset it to a clean
366+
// identity plan for the new doc — otherwise the board would re-apply the
367+
// (now-baked) rotation / reorder a SECOND time on the fresh raster (a
368+
// double-rotated preview), and the tool would read as permanently "dirty"
369+
// so a second Apply would bake yet another rotation. `doc.id` is preserved
370+
// across applyTransform and a rotate/reorder keeps the page count, so the
371+
// auto-init effect (which keys on page count) can't catch this on its own.
372+
patchToolState(ORGANIZE_ID, {
373+
order: Array.from({ length: survivors.length }, (_, i) => i),
374+
rotations: {},
375+
deleted: [],
376+
baseCount: survivors.length,
377+
});
348378
});
349-
}, [applyTransform, slice]);
379+
}, [applyTransform, slice, pageCount, patchToolState]);
350380

351381
return (
352382
<div className="flex flex-col gap-4">

0 commit comments

Comments
 (0)