@@ -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";
3938import { createPortal } from "react-dom" ;
4039import { AnimatePresence , m , variants } from "../components/motion.tsx" ;
4140import { 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" ;
4342import {
4443 compressPdf ,
4544 flattenPdf ,
@@ -51,7 +50,8 @@ import {
5150} from "../utils/pdf-operations.ts" ;
5251import { renderPagesToBlobs } from "../utils/pdf-renderer.ts" ;
5352import { 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" ;
5555import { Segmented } from "./panels/WholeDocPanel.tsx" ;
5656
5757const 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 >
0 commit comments