All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.13.0 - 2026-08-19
- Figures carry a caption:
Image.caption.Imagehadurl,alt_text,title,widthandheightand no way to record the text printed beside a figure, so two parsers had independently worked around the gap by writing captions intoalt_text. Those are different things — alt text substitutes for an image nobody can see, a caption sits next to one everybody can — and an image with both had to give one up. The field is typedstr | Noneto matchTable.captionand sits in the same position on the node. Formats with a native spelling now use it: AsciiDoc's block title, reStructuredText'sfiguredirective (an image directive is promoted when it has a caption), HTML's<figure>/<figcaption>, and MediaWiki's trailing caption field. Markdown has no caption syntax, so it reuses the two-part device built for table captions in #237 — a visible italic line plus a marker comment naming it a caption — with the caption placed below the figure, since that is where a figure's caption is conventionally set. The AsciiDoc and HTML parsers read their own spelling back, so those round-trip too. The PDF parser does not populate the field yet. Its caption detector was, at the time this field landed, a regex over a fixed 50pt band whose fallback accepted any capitalised text under 200 characters — on 20 cached research PDFs only 44 of the 155 strings it returned even began with a figure cue. Sinceinclude_image_captionsdefaults toTrue, binding that output to a field the renderers now print would have put spurious italic caption lines under most extracted images. The detector has since been rebuilt on the layout model'scaptionregions (see Fixed); routing its result into this field is the remaining step, and will be graded against the figure-binding oracle rather than assumed. (#338) - The AST has a home for figures: a
Figureblock container with children and a caption. A figure is not always one image — multi-panel journal figures embed one raster per panel, LaTeXML wraps every arXiv table in<figure>, and a vector-drawn PDF figure has a caption and no raster at all, whichImage.captionalone could not represent (#338).Figureholds block children (possibly none) plus an optionalcaption: str, mirroringTable.caption. All 16 renderers emit it — natively where the format has a spelling (HTML<figure>/<figcaption>, reST'sfiguredirective, LaTeX'sfigurefloat, AsciiDoc block titles, org#+CAPTION:), children plus an italic caption line elsewhere — and Markdown round-trips it through an extent-based variant of the #237 marker device (<!-- all2md:figure -->…<!-- all2md:figure-caption -->/<!-- all2md:figure-end -->). The HTML parser gains an opt-infigures_parsing="figure"mode that reads<figure>back as the container (made the default in a separate change, noted below); the PDF parser does not emitFigureyet — that follow-up is its own deliberate change.NodeVisitor.visit_figureis concrete rather than abstract (thevisit_markprecedent), so third-party visitors degrade to the figure's children instead of crashing; thefigure:/image:extraction selector now returns a multi-panel figure as one figure rather than N images. - The PDF parser emits
Figurecontainers. A captioned raster is now wrapped in aFigurewith the caption on the container (it used to ride onImage.caption, both unreleased), panels grouped by a layoutpictureregion or an identical detected caption fold into one multi-panel figure, and apictureregion holding no raster at all — a vector-drawn chart — becomes a caption-onlyFigure, because the caption is the only record the figure exists (#338, #340). Thepictureregion also rescues captions the per-image search cannot reach: a stacked panel's below-band finds the next panel, not the caption, while the region's extent ends where the caption starts. Caption body-copy suppression now follows emission: when OCR replaces a page orimage_placement_markersis off, no figures are emitted and caption paragraphs are no longer dropped from the text. Measured on the 12-article PMC born-digital sample,figure_bindingrose 0.47 → 0.56 (19 of 34 captions bound, both controls held at zero); the newly bound captions leave the prose stream, the same trade every bound caption already makes. The PMC oracle now counts aFigurecontainer once — not once per panel — matching JATS<fig>granularity. - The PDF parser recovers borderless tables from word-box gutters. Layout-predicted
table regions that PyMuPDF's strategies could not grid — 56 of the 63 tables missing
from the born-digital corpus, every one shredded by the text strategy and then
correctly refused by the split-word guard — now get a third pass that builds the grid
from the page's own word boxes: columns from vertical bands no word crosses, whole
words assigned to the column holding their center, wrapped cell lines folded into
their logical row with hyphenation repair across the join
(#386). Cut and space-joined
words are impossible by construction. Three measured guards keep prose out: a grid
needs three-plus columns (one gutter is what any two-column layout has), a
sequential-integer column beside sentence-length cells reads as a numbered
bibliography and demotes to prose (gridding one scrambles every citation), and a
region of predominantly rotated words is declined in favor of the rotation-aware
prose path. Measured on the 12-article PMC sample: tables emitted rose 12 → 30 of 32
expected,
table_content_similaritymedian 0.000 → 0.88,table_structure_similaritymedian 0.000 → 0.73, with whole-article attainable recall at 0.941 (baseline 0.951 — a table's cell stream breaks a few truth blocks' n-grams that its prose form kept) and both wrong-article controls at zero. - The PDF parser recovers rotated (landscape) tables in their own frame. The word-gutter pass declined any region whose words were predominantly taller than wide, because gridding a rotated table in page coordinates scrambles its reading order — measured, a 28x4 truth table came back 8x12 with its containment destroyed. Declining was the right call and the wrong ending: on the PMC born-digital corpus, 3 of the 13 still-missing tables were genuine landscape tables behind exactly this guard (#389). Such regions now go through the same gutter sweep with their boxes transposed into the table's own frame. Transposing is a reflection, so one axis always runs backwards for one of the two rotation directions — undecidable from the boxes alone, so both axes are checked against PyMuPDF's own stream order, which holds the words as they read, and mirrored where they disagree; getting that wrong would not mis-shape the grid but reverse its rows or columns, putting every cell in the wrong place. Dispatch demands stronger evidence than the old decline did: a word counts as rotated only when its box is taller than wide by a measured margin (real rotated table words sit at median aspect 2.5–2.7, a mixed-orientation region that must not be transposed at 1.05), because transposing upright text manufactures perfect fake "gutters" out of its line spacing. Ambiguous regions are still declined to the prose path, exactly as before — including the fourth rotated table in the deficit, whose orientation evidence is genuinely mixed.
- The word-gutter table pass admits two-column grids. A single gutter is what any
two-column layout has, so the pass shipped refusing it outright -- and that refusal
cost the 4 real two-column tables still missing on the PMC born-digital corpus
(
Questions | Answers,Male patients | 226 (69.8%)) to save junk the downstream guards were already catching (#389). Measured, the corpus's whole two-column population is 12 regions: the 4 real tables, 7 numbered reference lists, and 1 chart whose axis ticks and legend grid perfectly. Six reference lists were already condemned by the bibliography guard; the seventh numbered its entries2), a spelling the guard's integer pattern now counts. The chart is caught by a new drawing-density gate that runs only at the two-column tier: a chart's labels float over its plot's vector paths (541 in the measured region) while a borderless table has at most its own rules (0-4 in all four real ones). Wider grids carry two aligned boundaries, which chart labels do not produce, so no established path changes.
- Removed an unreachable second block-processing pipeline from the PDF parser.
_process_text_region_to_astand the five helpers it alone called (_process_text_blocks_to_nodes,_process_blocks_line_text,_process_blocks_line_monospace,_apply_column_detectionand_merge_columns_for_reading_order) had no callers anywhere in the package or the test suite. They were a near-duplicate of the live per-block path that had drifted away from it — same responsibilities, different rotated-text, code-block and heading handling — so reading them gave a misleading picture of what the parser actually does, and any fix applied to one copy silently missed the other. No behaviour changes: nothing called them. - Removed
enrich_metadata_with_conversion_info(utils/metadata.py). A repo-wide search found no reference to it outside its own definition — not imported, not exported, not called from any parser, test, or script. ~85 unmaintained lines, including a stream-position trap for file-like input. No behaviour changes: nothing called it. - HTML
figures_parsingnow defaults to"figure". A<figure>element parses to theFigureAST container introduced alongside #338 — children plus acaption— instead of degrading to aBlockQuotewith the caption folded into its prose. Callers that read the caption as paragraph text should readFigure.caption; the previous behaviour remains one option away (figures_parsing="blockquote"). With the default flipped, the generative figure round-trip gate now covers HTML alongsideastandmarkdown. - Default options no longer drop every PDF figure silently. The default
attachment_mode="alt_text"returned before extracting anything, so a journal PDF's figures left no trace — a 23-page arXiv paper with 251 embedded rasters produced zeroImagenodes (#340). The mode now runs a decode-free geometry pass and emits the figures that carry a detected caption, as URL-lessImagenodes whose caption renders through the caption marker device. Uncaptioned images stay suppressed under that mode: with no bytes and no caption, an![alt]()placeholder is noise, which was the sound half of the old rationale (#338). No pixmap is decoded on this path, so the default mode keeps its performance edge oversave/base64. Vector-drawn figures still yield nothing — they emit no raster placement to hang a caption on, and reaching them is #338's caption-bearing container, deliberately not attempted here. - Changelog entries are now written as
changelog.d/fragments, not as edits toCHANGELOG.md. Every branch appended its entry to the same place in the same file — under## [Unreleased], at the end of the same###section — so any sweep landing more than one PR hit a conflict inCHANGELOG.mdon every merge after the first, and resolving it by hand next to two thousand lines of prose is exactly the situation in which an entry gets dropped. A PR now addschangelog.d/<slug>.<category>.mdholding the bullets it wants published; two branches never write the same lines because they never write the same file.scripts/compile_changelog.py --version X.Y.Zfolds the fragments into a new released section at release time, updates the link references at the bottom of the changelog, and deletes what it consumed;--checkvalidates fragments without writing. Entries already sitting under## [Unreleased]were deliberately left there rather than migrated: the compiler merges hand-written content and fragments into the same sections by design, so the two styles coexist and nothing had to be rewritten to adopt this. towncrier and scriv were both rejected — they rewrite the whole changelog with their own newline and formatting conventions, which would turn a two-line release edit into a whole-file diff and flatten the long-form entries this project writes. Nothing enforces the fragment yet; adding a CI check that a PR touchingsrc/carries one is a separate decision. - Re-recorded
benchmarks/pmc/reference.jsonon post-mergemainand brought every figure on the fidelity page up to date with it. The tables story inverted: 164 tables emitted against 121 expected (was 92), with the surplus mostly continuation tables the expected count does not yet credit, and table-block text recall fell to 69.1% (was 83.6%) because table text now routes through structured cell extraction instead of flowing out as prose — a trade the page now states instead of netting away. The page also documents the new 110-article held-out corpus and its first validation result.
-
A nested ordered list numbered from anything but 1 no longer collapses into the paragraph above it. CommonMark lets only a
1.ordered item interrupt a paragraph. The Markdown renderer put a nested list on the line straight after its item's text, so a sublist starting at10.— or at0.— was read back as more paragraph text and the entire sublist vanished: twoListnodes went in, one came out, and the numbers ended up inside a sentence. This hit tight and loose parents alike, since loose rendering only put blank lines between items and never between a paragraph and the sublist inside one. The renderer now emits a blank line before such a sublist and renders the list containing it loose, which is what makes that blank line legal; the output is a fixed point, so the reparsed (now loose) document renders byte-identically. Renumbering the sublist from 1 was rejected: ordinal markers are real data — a PDF bibliography continues its numbering across list fragments, and rewriting it would silently falsify the citation numbers. Formatting note: a list gains blank lines between its items when one of them holds an offset-numbered sublist after some other block — that is the shape that was broken. A sublist that is an item's very first block sits against the marker, already reads as a nested list, and keeps its tight rendering. Lists with no offset-numbered sublist are untouched — tight stays tight, and the golden snapshot suite is unchanged. -
A PDF's ruling-line table fallback no longer deletes the text of any region it rejects. Text that falls inside a detected table's bounding box is removed from the page's ordinary text blocks before the table is validated, so that it is not emitted twice. The
find_tables()path knows this and hands the region's text back as a paragraph from every one of its rejection branches. The ruling-line fallback —_extract_table_from_ruling_rect, which reads a page's stroked lines directly — did not: it returned bareNonewhen extraction was switched off, when fewer than 2x2 ruling lines were found, and from each of its sparsity, uniformity and dot-leader/TOC guards. Every one of those deleted the framed region's prose outright, and the conversion still reported success. On a synthetic page holding a stroked frame with one internal rule and a sentence inside it,table_detection_mode="ruling"produced no output at all — the 2x2 grid was 75% empty, the sparsity guard rejected it, and the sentence went with it. All five paths now return the region's text as a paragraph, the same way thefind_tables()path does.table_fallback_extraction_mode="none"is included: it means "detect the region, don't build a table from it", and the region's text has already been excluded by the time it is honoured — but it is not counted as a table rejection, because nothing was rejected. -
A PDF list no longer nests a parent item underneath its own child.
_determine_list_level_from_xassigned each newly seen indent the levellen(x_levels)— arrival order — and never compared the x-coordinates to each other. The first list item of a run was therefore level 0 whatever its indent, and every new indent after it was one level deeper whether it lay to the right or to the left. A nested list that continues at the top of a column or page begins on a sub-bullet, which is routine in two-column typesetting; the sub-bullet took level 0, the genuine top-level bullet after it took level 1, and since the list builder reads a larger level as deeper, the parent list ended up nested inside its own child. Levels are now assigned by comparing x: within tolerance of an established indent is that indent's level, further right than all of them opens a deeper one, further left than all of them opens a shallower one, and an indent arriving between two known ones lands between their levels. The numbers are no longer 0-based or contiguous — they are an ordering key, and renumbering them would strand the levels the list builder has already recorded on its stack. A list run that starts on a sub-bullet now emits that sub-list in its own right rather than dropping it when the stack unwinds past its bottom. Not addressed: this is still blind to columns, so the first item of a right-hand column reads as deeper than anything in the left one. -
A PDF page whose text cannot be read no longer disappears without a trace.
_process_page_to_astwrapped itspage.get_text("dict", ...)call inexcept (AttributeError, KeyError, Exception): return []— no log line, no degraded event, no progress event. A page that failed to extract was indistinguishable in the output from a page that was genuinely blank, and the conversion still reported success; worse, if every page tripped it, the document-level OCR safety net saw an empty document and could put a perfectly good text PDF through OCR. The failure is now logged atWARNINGwith the page number and the underlying exception, and recorded as apage_text_extraction_faileddegraded event aterrorseverity so it reaches the confidence report. The tolerance itself is unchanged and deliberate — one unreadable page must not cost the other four hundred, and the parser is driven with mock pages that cannot answerget_text()at all — so the page is still skipped rather than raising. The in-placedehyphenate_blocks()call, which had drifted inside the sametry, has been hoisted out of it: it runs on blocks that were already read successfully, so a failure there is a bug in all2md rather than an unreadable page, and it was being laundered into the same silent empty page. -
A framed text box no longer becomes a one-cell PDF "table".
_pdf_tablesstates that its caps "apply to both PyMuPDF'sfind_tables()output and our ruling-line detector since both can fire on the same false-positive shapes", and thefind_tables()path enforcesMIN_TABLE_ROWSxMIN_TABLE_COLSaccordingly. The ruling-line detector did not: it asked only for two horizontal and two vertical lines, which is a single cell, so a stroked callout box that cleared the sparsity guard came out as one cell of prose wrapped in pipes — the exact shape those constants exist to reject. It now applies the same minimum to the grid the lines actually bound (len(lines) - 1per axis), and demotes what it rejects to a paragraph like every other guard. The same function had also re-hardcoded two shared thresholds as bare literals —> 0.70beside the importedMAX_TABLE_EMPTY_RATIO,>= 5besideMIN_FILLED_FOR_UNIFORMITY_CHECK— which happened to agree with them and would have drifted silently the first time either was retuned; both now read the constant. -
Chunk character spans stay on the basis they claim under
--avoid-table-split/--avoid-code-split. Every chunk is stampedchar_basis="section_text"— the span indexes the section's rendered Markdown — and that held only while a section was chunked in one piece. Segmenting a section around an atomic table or code block computed each segment's offsets against that segment's own text: the atomic piece got the constant span(0, len(text))and each prose segment restarted at 0. On a prose/table/prose section every chunk after the first therefore reported a span that overlapped its predecessors and, sliced out of the section text as documented, returned the wrong text — 45 of 119 chunks over this repository's README across five strategies. Each segment is now located in the section's own rendering and its windows shifted by that offset, so the spans are true, ordered and non-overlapping. Searching forward from the previous segment's end keeps two identically-rendered segments (two tables with the same cells) in document order. Rendering a fragment is not guaranteed to reproduce a substring of rendering the whole — footnote definitions, for one, are collected at the end of whatever document they are rendered in — so a segment that cannot be located keeps its segment-relative span and says so with a newchar_basis="segment_text", rather than reporting a section offset that is wrong. Checkchar_basisbefore slicing. -
An unknown keyword argument passed beside an options object now warns instead of raising
TypeError.to_ast,from_ast,to_markdown,convertandroundtripdocument a single rule for a keyword argument no options class has a field for: warn and drop it. That rule only ever ran on the branch where the caller passed no options object. Pass one —to_ast(src, parser_options=MarkdownParserOptions(), bogus=1)— and the kwargs went straight tocreate_updated, which isdataclasses.replace, so the same typo escaped asMarkdownParserOptions.__init__() got an unexpected keyword argument 'bogus'instead. The same branch could not reach a field of a nested options dataclass either:network_timeoutlives onoptions.network, the no-options branch has always folded it in, and the with-options branch raised on it even though it is a perfectly valid option for that format. Both are now handled the same way on both branches, with the existing warning categories and wording. Related: the kwargs thatto_markdown/convertpre-split were checked against the detected format's options class rather than the class of the options instance that actually receives them. Options classes inherit (MboxOptionsis anEmlOptions) and parsers accept any subclass of what they expect, so a caller could legitimately pass an instance whose class is not the format's own — and a field of that class, such asmax_messageson an eml parse, was dropped as "not for the formats in this conversion" and silently applied nothing. The split now uses the receiving instance's class when one is given. -
An Org greater block containing a blank line is no longer torn apart. The Org parser split a heading's body on blank lines and only then looked for
#+BEGIN_/#+END_delimiters, so a block whose contents contained a blank line — an ordinary thing to write in source code, and the only way to write two paragraphs in a#+BEGIN_QUOTE— was cut into fragments before anything could see it was one element. The fragment holding#+BEGIN_SRChad no end delimiter, the fragments after it re-parsed as prose (complete with Org inline markup applied to code), and the fragment holding#+END_SRCprinted that delimiter verbatim as body text:#+BEGIN_SRC python/x = 1/ blank /y = 2/#+END_SRCproduced aCodeBlockof justx = 1followed by a paragraph readingy = 2 #+END_SRC. Body segmentation is now aware of open blocks, the same way the file-property filter above it already was: a blank line inside a#+BEGIN_xregion is content, and only a matching#+END_xcloses it, so code that itself contains#+-prefixed lines stays intact. Because a greater block is an element in its own right, a delimiter now also bounds the elements around it when no blank line separates them — which additionally recovers text written directly after#+END_SRC, previously swallowed by the block and dropped. Affiliated keywords such as#+CAPTION:still attach to the block beneath them. -
An Org list item's wrapped continuation line is no longer deleted.
_parse_listkept only the lines that matched a bullet or number marker and had no branch for anything else, so a line continuing an item's text onto the next line — how any reasonably long item is written — simply vanished.- item one continues/onto a wrapped line/- item twoproduced a two-item list in whichonto a wrapped lineappeared nowhere at all, with nothing to indicate text had been dropped. A non-marker line now joins the preceding item's principal text separated by a single space, which is the rule the AsciiDoc parser was given for the same defect in #343; a wrapped item and the same item written on one line now parse to equal documents. Nested items are still flattened to a single level — the loop strips each line before matching it, so the indentation that marks a sub-item is gone before the marker is read — and that is unchanged by this fix. -
A single
;in AsciiDoc prose no longer creates a description list. The lexer's description-list pattern was^(.+?);(?:\s+(.*))?$, which matches an enormous amount of ordinary writing:Alpha; beta gamma.was lexed as the termAlphawith the descriptionbeta gamma., a line merely ending in;became a bare term with no description, and a semicolon anywhere on a wrapped line broke the paragraph it belonged to in two — the text before the wrap staying a paragraph and the rest becoming a definition list. AsciiDoc has no such marker; its description lists are written::,:::,::::or;;. The pattern now requires the doubled;;, which parses exactly as::does, and the class docstring'sterm::orterm;has been corrected. One existing test asserted the single-semicolon behaviour and has been updated: it encoded the defect rather than the language. -
Words no longer fuse across a formatting change in run-based formats.
group_and_format_runs— the shared helper that turns a paragraph's runs into inline nodes for PPTX (and available to any run-based parser) — joined each same-format group and then called.strip()on it. The comment claimed this preserved inter-run whitespace, and it did; what it also removed was inter-group whitespace, which is the only thing separating the words either side of a formatting boundary. Runs("This is ", plain),("bold", bold),(" and after.", plain)therefore producedText("This is"),Strong([Text("bold")]),Text("and after.")and every renderer emittedThis is**bold**and after.— a bug that fired on every bolded, italicised or underlined span with a space next to it. Whitespace at a group edge is now collapsed to a single separating space and re-emitted; whitespace inside a group is still preserved verbatim, whitespace at the true edges of the run sequence (the paragraph boundary) is still dropped, and a whitespace-only group between two content groups collapses to one space rather than vanishing. The separating space is always placed outside any formatting wrapper — on an adjacent plainTextnode where there is one, otherwise as its ownText(" ")node — because a space inside emphasis markers (**bold **) is not valid markdown. The PPTX text extractor's own compensation for the old behaviour (strip each run, then re-append a trailing space) has been removed now that it is not merely redundant but harmful: it dropped leading run whitespace, which the helper needs in order to see the boundary. The hyperlink-segment path keeps its separate boundary-space handling, since the helper is called once per link segment and cannot see across two of them. -
MarkdownRendererOptions.max_line_widthandtable_alignment_defaultnow do something. Both fields carried help metadata, so both surfaced as CLI flags and in the generated options documentation, and the Markdown renderer read neither — setting them changed nothing at all.table_alignment_defaultis now used for columns that state no alignment of their own: the default"left"still writes a bare---(a column with no alignment is left-aligned anyway, and spelling it:---would rewrite every table this renderer has ever emitted), while"center"and"right"write:---:and---:. A column with an explicit alignment is unaffected.max_line_width(stillNoneby default, meaning no wrapping and byte-identical output) now soft-wraps paragraph prose, and only paragraph prose — code blocks, tables, headings, link destinations and reference definitions are never touched. The wrap is deliberately timid: a paragraph containing a code span, a link or image destination, a reference label, an autolink, raw HTML or math is left unwrapped rather than broken at a guess, and a break is never taken in front of a word that would make the continuation line reparse as a new block (-,#,1998., and friends). Note that soft-wrapped lines come back as soft line breaks when reparsed, which is what a soft wrap means. -
Table and figure captions are escaped before being wrapped in the
*...*caption device. Markdown has no caption syntax, so a caption is rendered as a single-emphasis paragraph plus a marker comment naming it a caption. The caption text was interpolated raw, so its own metacharacters closed that emphasis early:Sales *2024* resultsrendered as*Sales *2024* results*and round-tripped toSales 2024 results, the literal asterisks silently deleted. Shapes that left the paragraph with more than one child failed the parser's single-Emphasistest outright, which lost the caption and leaked a stray italic paragraph plus the<!-- all2md:table-caption -->comment into the AST. Captions now go through the renderer's normal text escaping (and, like a table cell, have any newline flattened, since one would end the caption paragraph and break the marker triple); the ordinary text unescape on reparse gives the original string back. -
A line break inside a Markdown table cell or heading no longer destroys the block it sits in.
visit_line_breakemitted a real newline ("\n", or" \n"for a hard break) whatever the surrounding context was, and cell rendering escaped pipes but never newlines. A cell holding a hard break therefore rendered as| line1 <newline>line2 | b |, which splits the pipe row: the table reparsed with zero data rows and the remains became a stray paragraph. The same break in a heading ended the heading early and dropped everything after it into a paragraph of its own. Cells and headings now render their inline content in a single-line context: a hard break becomes<br>(GFM's spelling inside a cell, which our Markdown parser keeps as inline HTML in the cell it belongs to) and a soft break — a source-wrapping artifact — becomes a space, matching what the CSV renderer already did with the same nodes. Newlines arriving from anywhere else (aTextnode with an embedded newline, multi-line raw inline HTML) are flattened to a space as a backstop. The ASCII-art table fallback gets the same treatment, since a newline broke the grid it was being measured for. Line breaks in paragraphs are unchanged. -
A Markdown block quote nested in a list item is no longer indented twice. The quote's children were rendered to a string while the list item's marker-width indent was still active, so a child paragraph already carried that indent; the quote then prefixed
"{indent}> "onto the very same lines. For a marker four columns wide or more ("10. ", or any bullet at the second nesting level) the quoted text landed four-plus spaces past the>, and the round trip read it back as an indented code block inside the quote rather than a paragraph. Only quotes that were not the item's first child were affected, because the first child renders with the indent state already cleared. The indent state is now suspended while the children render and applied once, by the quote, as it prefixes each line. Material for MkDocs admonitions render through the same helper and now carry the indent on their!!!header too, so a nested admonition no longer splits its header off at column zero. -
Six table renderers no longer drop cells when a span collides.
BaseRenderer._layout_table_gridresolves declaredcolspan/rowspanvalues — which real documents routinely overstate — onto a grid, truncating a span rather than letting it overlap and widening the table rather than dropping a cell that no longer fits. Only the DOCX and PPTX renderers were migrated onto it; the reStructuredText, Org, LaTeX, ODT, ODP and PDF renderers each kept a copy-pasted fill loop that took the table's width from that layout but then placed cells at their declared spans, ending the row withif col_idx >= num_cols: break. Any cell pushed past the last column by an earlier row'srowspanwas discarded with no warning: a two-row table whose second row declarescolspan=3under arowspan=2neighbour lost its final cell's content entirely in all six formats. All six now consume the shared grid's placements and emit the effective spans, so a collision costs a merge instead of content. Well-formed tables render byte-for-byte as before. -
An email's attachment section is now built from AST nodes instead of Markdown spliced into the message body.
process_email_attachments()returned a Markdown string ("\n\n## Attachments\n\n"pluslines) that was concatenated ontomessage["content"]. A plain-text body — the common case — is emitted asTextnodes, so the Markdown renderer escaped the whole thing: an email with one PNG attachment rendered\## Attachmentsand!\[pic.png\], a broken heading and a dead image reference. The section is now aHeadingplus a paragraph ofImage/Linknodes, the same shape every other parser produces viaattachment_result_to_image_node, so it survives into non-Markdown renderers too. Three golden snapshots that had recorded the escaped output were updated. Affected the.eml,.mbox/Maildir and.msgpaths; on the latter two it was unconditional, since neither ever re-parsed the body as Markdown. -
MBOX and Outlook bodies converted from HTML or RTF are no longer flattened into escaped plain text.
parse_single_message()reportscontent_is_markdown=Truewhen it converts an HTML or RTF body to Markdown, and the EML parser has honoured that flag; the MBOX and Outlook parsers ignored it and split every body intoParagraph(content=[Text(...)]). The renderer then escaped the Markdown source, so a heading arrived as\## Headingand a link as\[text\](url). RTF bodies are converted unconditionally —convert_html_to_markdowngates only the HTML branch — so Outlook-originated mail reaching the mbox or PST path was mangled under default options. All three parsers now share oneparse_email_body()helper, and the PST branch sets the flag when it converts an HTML body. Structure loss also affected non-Markdown renderers, since headings and lists never became AST nodes at all. -
An Outlook
.msgfile now reaches the Outlook parser instead of the RFC-822 email parser. Theemlconverter claimed.msgin its extension list at a higher detection priority than theoutlookconverter, and extension matching runs before content sniffing — so any path or named stream ending in.msgrouted toEmlToAstConverter. Neither converter defines a content detector, so nothing corrected the choice afterwards. The RFC-822 parser does not reject OLE/CFBF input:message_from_binary_fileaccepts the binary happily and yields a header-less message whose body is the compound file's bytes decoded as text, soall2md mail.msgproduced mojibake rather than an error, andextract-msgwas never invoked. The same bytes without a filename already detected correctly asoutlookvia magic bytes..msghas been dropped from theemlextension list; its magic-byte patterns are all text mail headers and cannot match a compound file, so the eml parser loses no reachable input. -
Format detection no longer reads a sentence as a filename.
registry.detect_formatranos.path.splitextover anystrit was given, so a string holding document content was extension-matched on its tail:to_markdown("Reminder: check results.csv")detectedcsvand returned the one-cell table| Reminder: check results.csv |, andto_markdown("Payload attached as invoice.pdf")raisedFileNotFoundErrornaming a path the caller never passed — the failure class #233 fixed in the input loader, resurfacing one layer down because detection ran before the loader and derived its own answer. Detection now applies the loader's ownlooks_like_path_attemptrule, so the two agree: aPath, an openablestr, and a path-shapedstr(short, single-line, no whitespace, no://, ending in an extension all2md knows) all keep extension and MIME matching, and everything else is content. Path-ness is deliberately not gated on the file existing, sincedetect_formatis also called on output paths that have not been written yet. Such a string is now also handed to the content detectors, which never saw it before —contentstayedNonefor anystrthat was not an openable file. Inline string content therefore detects identically to the same content passed asbytes: HTML, JSON, XML and the rest are now recognised, and the structured-text detectors claim the same strings they already claimed on thebytespath ("- a\n- b"is YAML there and is YAML here). Where no detector matches, the fallback toplaintextis unchanged. -
Writing to
out.txtno longer silently strips every piece of formatting. When notarget_formatwas given,convert()inferred one from the output path — andregistry.detect_formatanswers"plaintext"for anything it does not recognise, including a.txtextension and any unknown one. That answer is a no-match signal, but it was used as a renderer choice, soconvert("in.md", "out.txt")rendered through the plaintext renderer:# TitlebecameTitle,**bold**becamebold, and[a link](https://example.com)lost its URL entirely. The code had a guard for exactly this, comparing the inferred format against"txt"and substituting markdown, butdetect_formathas returned"plaintext"— never"txt"— since it was rewritten, so the guard had been dead the whole time and the documented "defaults to markdown" behaviour never fired. All three sites (convert(), and the CLI's merge and single-file conversion paths) now compare against"plaintext". Only inference is remapped: an explicittarget_format="plaintext"or--to plaintextstill selects the plaintext renderer, and a recognised extension such as.htmlor.docxis still honoured. -
PPTX run hyperlinks are no longer discarded.
_process_paragraph_runs_to_inlinebuilt aLinknode for every hyperlinked run into a localresultlist, but both of the function's exit paths returnedinline_nodes(the output ofgroup_and_format_runs, which never captures hyperlinks) instead — the hyperlink loop was dead code, so every PPTX hyperlink's URL was silently dropped and only the anchor text survived. Runs are now split into consecutive stretches by hyperlink address; each stretch is formatted independently (so bold/italic inside a link's text still applies) and hyperlinked stretches are wrapped in aLinknode, preserving run order relative to surrounding plain text. -
PPTX grouped shapes no longer drop all of their contents.
_process_shape_to_asthandled text frames, tables, images, and charts and then fell through toNonefor everything else. AGroupShapehas none of those attributes itself — its content lives entirely in its member shapes — so slides with a grouped diagram, SmartArt-converted shapes, or manually grouped callouts (all extremely common in real decks) lost that content with no warning and no degraded-content signal. The parser now recurses intoshape.shapesfor anyGroupShape, in document order, and handles groups nested inside groups. -
A PDF figure caption is now taken from the layout model's
captionregion, not guessed from a fixed band of text near the image. The old rule matched a figure cue (Figure 3) against a 50pt band above and below the image and, failing that, accepted any text under 200 characters beginning with a capital letter — which on a journal page is a running head, an author list, or an ordinary sentence. Scored against JATS figure captions over 12 PMC articles, it returned 32 strings of which 19 were captions (59% precision, 50% recall). Reading thecaptionregions the layout model already predicts and binding the nearest one below the figure — below rather than above, which is the convention for figures and the opposite of a table's — returns 31 strings of which 27 are captions (87% precision, 74% recall). Wherepymupdf-layoutis not installed the cue match still applies but the catch-all fallback is gone, which trades 6 points of recall for 20 of precision (79%/44%). Requiring a cue on top of a layout region was measured and rejected: 0.9 points of precision for 8.8 of recall, because real captions do not all open with the word "Figure". No output changes yet — the detector's result still reaches nothing, and routing it intoImage.captionis the next step. (#338) -
An image next to a blank line is no longer silently dropped from a PDF. The caption detector checked a text band for content before stripping it, so a band holding only whitespace became an empty string and then raised
IndexErrorontext[0]. Image extraction wraps each image inexcept Exception: continue, so the crash never surfaced as an error — it discarded the whole image, after it had been successfully decoded and encoded. Measured at 1 image in 70 across 20 PMC articles, insaveandbase64modes withinclude_image_captionsleft at its default ofTrue. (#338) -
A nonexistent
Pathinput no longer raises a misleading "Unsupported input type."LocalPathRetriever.can_handle()returnedFalsefor aPaththat does not exist, and no other retriever accepts aPath, so the loader fell through to a generic "Unsupported input type: WindowsPath"ValidationErrorinstead of the accurate, path-naming errorload()already raised but could never reach.can_handle()now accepts anyPathinstance, lettingload()'s existence and file-vs-directory checks run and surface their message — "Path does not exist: ..." — instead. -
An AsciiDoc hard line break inside a table cell no longer splits the row.
AsciiDocRenderer.visit_line_breakemitted' +\n'for every hard break regardless of context, and a table row is written as one source line, so a cell with an embedded hard break carried a literal newline into the middle of it. The project's ownAsciiDocParserreads rows line by line: re-parsing'line1 +\nline2 |b'produced a one-cell row ('line1 +') followed by a spurious second row ('line2','b') instead of the original two-cell row, and the' +'marker leaked into the first cell's text. The renderer now tracks whether it is inside a table cell and, in that context, renders a hard break the same way it already renders a soft one — as a single space — since AsciiDoc has no in-cell line-break idiom this project's parser reads back as anything other than a new row. -
Text after a MediaWiki list or
:block quote is no longer silently dropped. mwparserfromhell lumps everything up to the next markup construct into a single Text node, so the paragraphs that follow* item\n* item\nSome text.lived inside the last list item's Text node — the parser truncated that node to its first line for the item and discarded the rest, along with the trailing paragraphs. The list/quote parsers now return the unconsumed remainder so the caller can run it back through normal paragraph processing; a list or quote whose Text node ends exactly at the last marker line still emits no stray paragraphs. -
MediaWiki inline formatting no longer fuses with the words around it. mwparserfromhell splits a paragraph into one Text node per inline-markup boundary, so
"This is '''bold''' text."parsed toText('This is'), Strong([Text('bold')]), Text('text.')— every fragment fully stripped of its separating space — and rendered asThis is**bold**text.. Text fragments now collapse internal whitespace runs to a single space instead of stripping them away, keeping the one space that separates a fragment from a neighbouringStrong/Emphasis/Linknode; only the true edges of a paragraph or heading (trimmed once, when the fragment buffer is flushed) are stripped, so trailing newlines and leading blank lines still produce clean paragraph/heading text with no stray edge whitespace. -
The PDF image-caption fallback no longer treats a plural sentence opener as a figure cue. The fallback (used when the layout model has no
captionregion for an image) matches a line against a "Figure 3" / "Fig. 2b"-style opener, but the pattern allowed zero whitespace before its letter alternative and matched the whole thing case-insensitively — so[A-Z]also matched the lowercase trailing "s" of a plural: "Figures in this study", "Images were acquired using a confocal microscope" and "Tables 1 and 2" all satisfied the cue as if the "s" were a figure-letter locator like "Fig B". The locator half of the match (the digits or letter after the keyword) is now checked case-sensitively and, for a letter, requires real whitespace before it, so a plural opener's trailing "s" can no longer stand in for one. Genuine cues ("Figure 3:", "FIGURE 4", "Fig B", "Table 1.") are unaffected. -
header_min_occurrencesnow actually filters by occurrence. The option is documented as "minimum occurrences of a font size to consider it for headers" (its docstring's stale "default 3" is also corrected to the real default, 5), but the statistic it was checked against accumulated characters, not occurrences:fontsizes[size] += len(text)per span. A font size needed fewer than 5 rendered characters — trivial for almost any span — to be dropped, so the filter was a near no-op regardless of how many times that size actually appeared. A separate line-occurrence count is now tracked and checked againstheader_min_occurrencesinstead. Body-text detection ("which size covers most of the page") still runs on the character-count statistic, unaffected by this change, since characters and occurrences answer different questions and a paragraph condensed onto one packed line should still out-rank a heading for body status. The single largest font size on the page is exempt from the occurrence check: by convention it is the document's title, which renders once by design and would otherwise never clear a repetition threshold now that repetition is measured for real. Every other size — subordinate headings, and any one-off oversized span the filter exists to catch — is filtered as documented. This is a real behaviour change for header detection: a font size that previously qualified on character count alone but occurs only a couple of times (and is not the page's largest) will no longer be treated as a heading size. -
WebArchive subresource extraction no longer silently drops files on Windows.
_extract_subresourceshad two defects, both masked by the method's blanketexcept Exception: logger.warning(...). A text subresource (a plist<string>, not<data>) was written withPath.write_text()and no explicit encoding, so on Windows — where the platform default is cp1252 — any non-Latin content raisedUnicodeEncodeErrorand the resource was dropped; it's now written as UTF-8. Separately, the filename was taken asPath(resource_url).name, which keeps a URL's query string (img.png?v=1);?is illegal in a Windows filename, so the write raisedOSErrorand the resource was dropped. The filename is now taken from the URL's path only (query string and fragment stripped) and run through the existing attachment-filename sanitizer. -
A detected PDF figure caption now reaches
Image.captioninstead of being discarded. The caption was routed throughfallback_alt_text, a dead path: extraction writes a non-empty placeholder alt text (Image from page N), so the fallback never fired andinclude_image_captions=Truecould not affect output in any attachment mode. The caption now rides on the node'scaptionfield — visible page content set beside the figure, not a substitute for it — and the Markdown renderer already round-trips it as an italic line plus a marker comment. The defaultalt_textmode still extracts nothing; that is the remaining half of the defect and is tracked separately. (#340) -
DokuWiki footnote definitions no longer fuse their paragraphs into one token. A multi-paragraph definition was rendered through the inline path with nothing between the blocks, so
first paraandsecond paracame outfirst parasecond para-- a destroyed word boundary, the same corruption class fixed for reST and Org definition lists (#347). Blocks now render separately and join with a space. The paragraph boundary itself is still lost -- DokuWiki's inline footnotes have nowhere to carry it -- and the round-trip fuzzing gate continues to document that; the words survive. -
Org footnote definitions keep their paragraphs across a round trip. Org continues a footnote definition across a single blank line and ends it at two, but neither side spoke that dialect (#347): the renderer joined a definition's paragraphs with a bare newline (one continuation line on re-parse) and followed a definition with a single blank line (which would swallow the next block), while the parser's block splitter discarded the blank counts entirely. The renderer now separates a definition's paragraphs with one blank line and follows a definition with two; the splitter counts the blank lines between blocks, and single-gap paragraph blocks after a
[fn:id]join its definition. Also ported the boundary-break hoisting cure (#391) to the Org span delimiters (/,*,+,_): a hard break at a span's edge stranded the delimiter at a line start, where*opens a headline and+a list item. -
reST footnotes survive a round trip with their identifiers and paragraphs. Three defects stacked (#347): the renderer spelled every footnote
[a1]_/.. [a1], which for an alphanumeric label is reST citation syntax, so the footnote stopped being one -- non-numeric identifiers now use the named auto-numbered form ([#a1]_/.. [#a1]), with escaped whitespace (word\ [#a1]_) where a marker rides a word, since docutils only starts inline markup after a boundary. A multi-paragraph definition rendered as continuation lines and read back as one paragraph -- its blocks now separate with blank lines at the marker's body column, and hard breaks inside a body fall back to raw newlines so|line-block syntax cannot displace it. And the parser preferred docutils' normalized anchors (ids, e.g.footnote-1) over the label as written (names), mangling identifiers even for plain numbered footnotes; it now takes the name on both definitions and resolved references, and resolves docutils' internal\x00escape markers instead of copying them into text. -
reST and Org definition descriptions no longer fuse or lose words. Both renderers concatenated a description's paragraphs with nothing at all between them, so
alphaandbetacame back as the single tokenalphabeta-- a destroyed word boundary, not a lost break (#352). The reST renderer now separates blocks with a blank line at the same indent, which round-trips the paragraph count exactly; the Org renderer joins blocks as indented continuation lines, so the boundary degrades to a line rather than a fused word. Worse than the reported fusion, the Org parser silently deleted every definition line that did not start a new- term ::item -- a wrapped definition lost all but its first line; continuation lines now join the open definition. A term's several descriptions still flatten into the one definition each syntax can hold (docutils: one definition per term; Org: one::per item) -- words intact, count inherently lost -- and the fuzzing-gate entries now say exactly that. -
PDF: a subsection heading printed directly under its section heading is no longer fused into it. The wrap-merge that reassembles a long title set on two printed lines had no width test, so
MethodsoverStudy designbecame one heading — and both section titles went missing, the largest single class (~30%) of the heading residual on the PMC born-digital corpus (#400). A line only wraps because it filled its measure, so the merge now requires the first line to fill at leastHEADING_WRAP_MIN_FILL(0.8) of the two lines' shared width — a threshold read off 307 labeled merges: true wraps fill 0.852–1.0, the separable fused band 0.22–0.84. Pairs whose first line is the wider one (Methods and DesignoverStudy design) remain geometrically inseparable and still merge; that residual is documented on the issue. -
The AsciiDoc renderer/parser pair stops rejecting, corrupting, or leaking its own output. Four defects, one format. A bold or italic span wrapping only a hard break stranded its delimiters at a line start, where
*is a level-1 list marker and**a level-2 one -- the first silently turned emphasis into a list item, the second crashed the parser outright, the round-trip matrix's only open crash (#353); boundary breaks now hoist outside the delimiters, the same cure as markdown's #391. A definition-list description on the line directly below itsterm::-- the standard placement, and what this renderer itself emits -- parsed to nothing: the term came back empty, the text as a sibling paragraph, and the list split at every term (#351); unindented lines adjacent to the term now bind to it as one wrapped paragraph. The renderer also fused a description's paragraphs into one token (only+extra->onlyextra, #352's class); blocks now join as continuation lines. And the parser did not recognise the named inline footnote formfootnote:a1[text]-- valid Asciidoctor and the renderer's own spelling -- so the raw markup leaked into the prose as literal text (#346); it now parses, a hard break inside the macro's brackets degrades to a space instead of an unparseable embedded newline, and an id referenced but never defined gets an empty definition rather than an unbalanced round trip. -
The Markdown renderer no longer emits markdown its own parser misreads. Three defects shared that shape. A spanned table was written one pipe cell per AST cell while the delimiter row was sized to the logical width, so a
colspanheader made the cell counts mismatch and GFM read the whole table as prose; cells are now placed on the resolved grid, spans padded with empty cells -- the merge is lost (pipe tables cannot express one), the table is not, and rows after arowspanstay in their own columns instead of sliding left (#385). A hard line break was always spelled as two trailing spaces, so a break on a line with no visible text (one break following another) left a whitespace-only line, which is a paragraph boundary in every conformant parser -- consecutive breaks silently split their paragraph in half; such breaks now use the backslash spelling, which puts a visible character on the line (#384). And an emphasis, strong, or strikethrough span wrapping nothing visible -- or ending in a line break -- stranded its delimiter run alone at a line start, where***is a thematic break and~~~~opens a tilde code fence that swallows the rest of the document; nested strikethrough now renders its inner content bare (GFM strikethrough does not nest), spans over nothing visible emit no delimiters, and boundary breaks are hoisted outside the delimiters (#391). The round-trip fuzzer's figure-gate strategies were constrained to avoid the first two defects and its footnote allowlist carried the third under a wrong attribution; the constraints and the stale allowlist entry are removed, so the gates now guard all three classes. -
The PMC born-digital lane runs again. Moving the corpus fetchers' XML parsing to
defusedxmlleft the scheduledPMC Born-Digital Fidelityworkflow crashing at import: it syncs a deliberately lean environment (--extra pdf_layout --extra ocr), anddefusedxmllived only in format extras the lane does not install, so the 2026-08-15 scheduled run died in 29 seconds before scoring a page. A run that cannot execute also cannot count toward the lane's exit criterion (two consecutive clean scheduled runs before a fidelity baseline is recorded), so this was holding the gate open. What the benchmark lanes import beyond the library now has its own named home — abenchmarksextra — instead of borrowing from a format extra that happened to carry it. -
reST line blocks are no longer silently dropped. A
|line block fell through the parser's unknown-node branch, so every line of it vanished from the output -- text loss, not formatting loss. A line block now parses as a paragraph whose lines join with hard breaks; nested (indented) lines flatten into the same paragraph, since the AST does not model their indentation.
- An HTML
<figcaption>no longer overwrites the image's alt text underfigures_parsing="image_with_caption". It binds toImage.caption, so a figure that carries both a realaltattribute and a caption keeps both. Previously the caption was only absorbed when the image had no meaningful alt text of its own, and absorbing it destroyed the alt text when it did. A caption with no image to bind to is still emitted as its own paragraph rather than dropped. - The born-digital lane records why a table region was rejected, and how many were
(payload schema 4). The parser distinguishes nine reasons for rejecting a table region and
coalesces repeats of
(parser, kind, detail, severity)while summing their counts. The lane read back onlykind, and then counted coalesced event objects — so an article rejecting twelve regions and one rejecting a single region contributed identically, andtable_rejected: 101was neither a count of regions nor of articles but of event objects, which is the least meaningful of the three. Nothing downstream could tell a corpus-wide detection failure from one pathological document, and nothing could tell an improvement from a regression, because some of those reasons are the parser correctly refusing to grid a page of prose.degraded_eventsnow reports occurrences broken down by reason, with the number of articles each reached beside it — the two answer different questions and diverge sharply here, since a single article contributed twelve of the twenty-ninetext_grid_splits_wordsrejections measured across twelve articles. This is a benchmark payload change only; no conversion behaviour changes, and no published figure moves.
-
A list item's text may run onto the lines below it (AsciiDoc). AsciiDoc wraps freely: the lines after
* itemare part of that item until a blank line, another item, or a block. The parser never implemented this. Where it was merely lossy, the run-on line surfaced as a sibling paragraph after the list instead of as the item's own text; where it was worse, the stray line ended the list, so a nested item on the next line had no level-1 parent and the conversion failed outright withValueError: Cannot nest to level 2 without a parent item at level 1. Three lines of ordinary hand-written AsciiDoc —* a,b,** c— were enough to hit it. The joining rule, hard breaks and all, already existed for paragraphs and is now shared with list items rather than duplicated, and it merges the text nodes a wrap leaves adjacent, so* a/band* a bnow parse to equal documents. Found by the generative round-trip gates on their first per-PR run. (#343) -
One rejected table region is no longer counted as two (PDF). When the layout model predicts a table region and a grid is found there but then refused, the refusing guard records its own specific reason —
text_grid_splits_words,degenerate_grid,mostly_empty. The method that owns this already carried a note forbidding the vaguerlayout_region_not_tabularfrom being added on top, because that counts the same region twice and takes a second bite out of the confidence score. The flag the note relies on did not work for the case it was written for:found_gridwas set after the split-word guard, and that guardcontinues, so the flag was only ever true for grids that survived — never for a grid found and then rejected. The signature is unmistakable on the born-digital corpus. Across 12 articles the two reasons appear in exact 1:1 correspondence in every affected article — 1/1, 4/4, 6/6, 12/12, 29 each in total — which is one region counted twice rather than two regions rejected. So the confidence score was penalised twice for a single refusal, and the benchmark's headlinetable_rejectedcount roughly doubles its dominant case, which matters because that number is read as "tables we threw away". The flag is now set when a grid is found, which is what its name and the note both already said it meant. The existing regression test asserted this property but only on the arm that satisfied it: it driveslines_strictwith a degenerate grid, reaching the foot of the method with the flag already set. The new one drives the text-alignment arm where the defect lived, and asserts the recorded reasons rather than the counter — a counter cannot see one region rejected under two names. -
The PMC born-digital corpus is back to 66 articles.
PMC11000011.1was withdrawn upstream (#329), and since #330 the lane degraded gracefully and scored 65 of 66. That is the right behaviour for an incident and the wrong steady state: a permanently-falsecomplete_corpusis a permanently-yellow test, so the second withdrawal would be invisible against a run that already looks like that — and it quietly consumes the tolerance budget that exists for real incidents, which aborts the load past a tenth of the selection. The replacement is drawn by the selection process rather than chosen: only thePMC11000000seed was re-walked, through the samebuild_manifestentry point with the committed stride,per_seed, candidate cap and filter, because a hand-picked article would bias the corpus in a way nothing downstream could detect. The walk also shows the withdrawal is a rejection rather than an outage — the prefix still lists and only the PDF 404s — so stride alignment is preserved and the seed's two surviving members re-select byte-for-byte identically. Only the third slot moves, toPMC11000033.1. The pin moves34cc7c50→9ba5c0cd, so the next run uses a new digest-keyed cache directory. Re-recordedbenchmarks/pmc/reference.jsonagainst the new pin on CI, and updated every figure indocs/source/benchmarks.rstin the same change. The headline readings barely move, which is what a representative corpus should do when one article of 66 is swapped: recall of attainable text 95.3%, novel n-grams 1.0%, both unchanged to the published precision. Tables go from 88-against-117 to 92-against-121 (#332). -
The published fidelity figures are now checked against the artifacts they come from.
docs/source/benchmarks.rstis the one place the project makes a public quality claim, and the claim rests entirely on each number coming from a committed artifact — but nothing enforced the correspondence. The page and the artifacts were written in the same change and agreed on the day; the first time a pin moved they could silently stop agreeing, and a stale figure on that page reads exactly like a measured one. This lane had already published a comparison between two runs covering different corpora, so the failure mode is demonstrated rather than theoretical. The check is built the strong way round: rather than scraping numbers out of the prose and asking whether each looks plausible — which cannot see a figure that should be there and is not, and needs an exemption for every incidental integer — each published claim is declared as a snippet rendered from the artifact, and the page must contain it verbatim. Matching the whole line rather than the number alone means a stale line fails too. Verified the judge can fail: against the pre-update page it reports exactly the nine figures that moved, while the unchanged OmniDocBench readings still match. Two stronger invariants ride along, because matching rendered text still assumes the artifact itself describes the right corpus.reference.json'scorpus_pinmust equal the SHA-256 of the committedmanifest.json, so a manifest edit landing without a re-record — precisely what #332 had to repair — fails immediately rather than at the next scheduled run; and the published reference must be a complete run covering every article the manifest names, sincecomplete_corpusgoing false is the right signal for a run and the wrong state for the artifact a public page quotes as its reading. -
Three AST walkers stopped dead at List and Table nodes.
AddAttachmentFootnotesTransform._collect_footnote_refs,GenerateTocTransform._collect_headings, andGenerateTocTransform._inject_heading_idsrecursed only throughhasattr(node, "children")/hasattr(node, "content"), butListstores its items in.items,Tablein.header/.rows,TableRowin.cells, andDefinitionListin.itemstuples — none of which the check saw, so recursion silently stopped the moment it reached one of those nodes. In practice, the CLI'sadd-attachment-footnotestransform never emitted aFootnoteDefinitionfor an empty-URLImage/Linknested in a list or table cell, andGenerateTocTransform(Python-API-only) omitted headings nested the same way from the generated TOC and never injected their ids. All three walkers now recurse via the sharedget_node_children()helper (src/all2md/ast/nodes.py) that every other AST walker in this codebase already uses, so they see every node type uniformly._inject_heading_idsalso had to gain a hand-rolledDefinitionListcase, sincereplace_node_children()deliberately refuses to rebuild that node's(term, [descriptions])tuple structure generically. -
HTML: bare text directly under
<body>(or in a body-less fragment) is no longer discarded. The top-level walk only processedTagchildren of<body>/root and skipped everyNavigableString, so<body>Loose text. <p>Paragraph.</p>Trailing text.</body>kept only the<p>, and a pure fragment likeJust plain text with <b>bold</b> insideparsed to aDocumentcontaining justStrong('bold')with all the surrounding plain text deleted. The rest of the parser already had a mechanism for this —_process_block_containeraccumulates adjacent inline content and flushes it into a singleParagraphwhen a block sibling is reached — so the top-level walk now delegates to it instead of duplicating a narrower, buggy version of the same logic. Whitespace-only text between block elements still produces no paragraph. -
XLSX cell hyperlinks render as real links again, instead of escaped literal text. The parser built a markdown-syntax string (
[text](url)) for a hyperlinked cell and handed it tobuild_table_ast()as plain cell text, which wrapped it in aTextnode — so every renderer saw an ordinary string, not a link. The Markdown renderer escapes brackets inTextcontent, so a hyperlinked cell rendered as\[text\](http://x)instead of a working link, and the HTML renderer emitted the literal bracket syntax instead of an<a>tag. The parser now extracts each cell's (text, hyperlink URL) pair and, for a hyperlinked cell, builds a realLinknode directly in theTableCell— the same thing the DOCX, PPTX and HTML parsers already do.build_table_ast()(shared by CSV, ODS and XLSX) gained a second accepted cell shape for this: a cell can still be a plain string, unchanged for every other caller, or a pre-builtlist[Node]used as-is. ODS has no cell-hyperlink extraction at all yet, so it is unaffected and left as-is.
1.12.0 - 2026-08-12
- A conversion fidelity page (
docs/source/benchmarks.rst), with a committed evidence artifact behind it (benchmarks/pmc/reference.json). The benchmark lanes have been producing numbers for several releases and none of them were published anywhere a user would look: the documentation could say all2md converts documents well, but not how anyone would check. Every figure on the page now comes from a committed artifact and is printed beside its control — the same measurement applied where it ought to fail — because on most text metrics the highest-scoring converter is one that dumps the raw text layer with no structure at all, and a fidelity score with nothing to falsify it is not evidence. The born-digital reading, over 65 articles and 699 pages of publisher PDFs scored against publisher JATS: 95.3% of attainable text recovered against a 0.4% wrong-article control, and 1.0% of emitted n-grams novel — containing a word the document does not have anywhere — against a 0.7% control. Both figures are reported with the denominators that make them readable rather than flattering, and the page explains why raw recall (60.1%) and raw unsupported output (6.2%) are the wrong numbers to quote. It is candid where the numbers are weak. Tables are the worst area and say so: 88 emitted against 117 expected, with detection rather than extraction as the bottleneck. It also states what the lanes structurally cannot see — every corpus is English, so a change that deleted all CJK, Cyrillic and Arabic text would score perfectly on all three. - Content precision beside content recall on the born-digital lane
(
benchmarks.pmc.article.measure_precision). Recall alone cannot be trusted, and this lane has already been misread once because of it: the highest recall available on this corpus comes from emitting the raw text layer with no structure whatsoever, so recall rising is not by itself good news. The new instrument asks the converse — does the output contain anything the document does not — against the PDF's own text layer rather than JATS, because JATS is not what the page prints and scoring against it would charge the parser for reproducing the document faithfully. Raw precision turned out to need a denominator of its own, for the same reason raw recall needed its attainable ceiling. Most of what a correct conversion emits unsupported is the document's own words in an adjacency the text layer does not have: all2md orders columns and joins blocks, the layer comes out in PyMuPDF's order, and every disagreement mints n-grams at the seam. Measured over five articles that is 4.8% of emitted n-grams, against 0.5% carrying a word the layer never has anywhere — so reporting the raw figure would have made the result nine times worse than the parser deserves. The two are reported separately, andnovel_shareis the figure to read. Duplication is counted apart from both, because a block emitted twice is an unchanged set and a doubled multiset — no set-based score can see it. Both figures ship with the mismatched-article control the lane already applies to recall. - A PDF conversion guide (
docs/source/pdf.rst). PDF is the format all2md works hardest at and the one with no dedicated page: the deepest hand-written coverage was five bullets in the overview that predated layout analysis entirely, and of the twelve OCR flags, nine appeared only in the generated option reference — listed, never explained. The new page starts from what a PDF actually is (positioned glyphs, so every heading, table and reading order is inferred) and then covers the two per-page routes through a document, layout analysis and whypdf_layoutis excluded fromall, the table strategies and the whole-word guard on the borderless fallback, column ordering, the heading/list/dehyphenation rules that explain otherwise-arbitrary output, and whenautoOCR fires — including thatimage_area_thresholdchanged meaning as well as default this release. It records the trade-offs as measured numbers rather than claims, and it is candid about the limits, such as list items the PDF prints with no marker at all, which no marker rule can recover. The overview and format pages now link to it instead of restating a stale summary. - Every registered format's options class is now importable from both
all2mdandall2md.options. Twenty were missing fromall2md.optionsand thirty-one from the top level, including whole formats —ArchiveOptions,EnexOptions,MboxOptions,OutlookOptions,WebArchiveOptions, and the JSON, YAML, TOML, INI, Textile, OpenAPI and BBCode option classes — so configuring those converters from the Python API meant importing from a private submodule path. The split did not track anything:ArxivPackagerOptionswas reachable fromall2mdbut not fromall2md.options, which is backwards from every sibling. The two surfaces are now derived from the converter registry and a test fails if either drifts from it again, so adding a format cannot quietly skip the export (#184). OCROptionsandMetadataRenderPolicyare importable fromall2mdandall2md.optionstoo. Both are the declared type of a field on an exported options class —PdfOptions.ocrandBaseRendererOptions.metadata_policy, the latter reaching every renderer — so a caller has to construct one to set that field, and neither was reachable from either public surface. Configuring OCR from the Python API meant importingall2md.options.common, which thePdfOptionsdocstring told you to do; passing a plain dict instead did not work, and failed deep inside parsing with'dict' object has no attribute 'enabled'rather than at the call. The check that was supposed to catch this could not: #184 derives its expectation from the converter manifest, which names only format options classes, and a nested configuration group belongs to no format. The new rule is derived rather than listed — any dataclass appearing in a public options class's field annotations must be public itself — and it resolves the annotations before looking, because this package usesfrom __future__ import annotationsand 91% of those fields carry their type as a plain string, so the obvious version of the check inspects 89 of 982 fields and reports success.layout_feature_set, choosing which layout classifier reads the page (--pdf-layout-feature-set, requirespymupdf-layout; inert otherwise). The package bundles three:imf+rf(the default, image and text-geometry features),imf(image only) andrf(text geometry only). Which one is right depends on the document, so it is now a searchable option and is registered as anoptimizeknob rather than decided globally. The difference is not subtle: on a two-column reference page the image-feature models read the dense left column as a table — deleting nine reference entries before the fix elsewhere in this release, and splitting one table into two on three further pages — whilerflabels all 41 entries correctly and predicts no table at all. Across 20 born-digital articlesrfled on every axis measured (title recall 0.9916 → 0.9972, identical table content recall, three fewer spurious table nodes on the same 16 pages, 29% faster for skipping image inference). The default is deliberately unchanged: that is one corpus of one document kind, and image features plausibly earn their place on scanned pages, of which it contains none. Models are now cached per feature set instead of in a single global — an unkeyed cache returned whichever model loaded first, which would have made every arm of a search over this knob identical and the setting look inert.- A pinned PMC Open Access corpus for born-digital PDF benchmarking
(
benchmarks/pmc). The existing external ground-truth lane is 981 rasters, so it measures the OCR path; nothing external covered text-layer extraction, vector table detection or layout-derived reading order, which is most real-world PDF conversion. Articles come from thepmc-oa-opendatabucket, where each versioned prefix holds the publisher PDF beside its JATS XML — publisher-produced ground truth with real sections, paragraphs and table cell markup. The bucket has no corpus-wide revision to pin against, so a committed manifest of SHA-256 digests for both files of all 66 articles takes that role: loading never lists the bucket, revalidates every byte, and keys its cache by the manifest digest. Selection keeps an article when its JATS has at least one<p>and its PDF carries vector drawings; every rejected candidate is recorded by reason. Corpus bytes are not committed, and this ships no oracle or gate — those follow separately. Acharacterizecommand measures what the built corpus actually contains, deliberately independent of the filter that selected it: 750 pages, 100% with a text layer, 81.1% with vector drawings, 0% with the single-full-page-image scan shape — the inverse of the raster lane, and the zero was checked against a known scan first to confirm the test can fire at all. Analigncommand measures whether article-level JATS truth can be projected onto PDF pages: 95.7% of blocks place onto a page or an identifiable adjacent pair, with a 1.9% miss rate. Placement is content-only, never using extracted reading order, so it cannot quietly grade the reading-order metric against itself — and it ships with a control that scores every block against a different article's pages, where the false-placement rate is 0.8%. - A born-digital scoring lane over that corpus (
benchmarks.pmc score). Each article is converted once and every page scored against the JATS truth projected onto it, through the same oracle the raster lane uses, so a number here means what a number there means. Page boundaries come from the parser's own per-page separators, which keeps cross-page context intact — splitting the PDF into single-page files would hide exactly the defects this lane exists to find — and a dropped page raises rather than silently shifting every later page's ground truth. Blocks that will not resolve to a page are excluded and reported as an error budget printed with every run. Three controls ship inside the run: each page is scored again against the next page of the same article, against deliberately reversed, scrambled and halved output, and with OCR left enabled in auto mode so "no page needed OCR" stays a measurement that could fail rather than a configuration. Two dimensions are reported but flagged unusable as gates, with the measurement that disqualified them —block_structure_similarityseparates own-page from wrong-page output by only ~0.06 and rises when half the content is deleted. Whole-article content recall ships with its attainable ceiling: only 61.1% of JATS blocks are recoverable from the PDF text layer by any parser, because structured citations and bylines record words in an order the page never prints, so raw recall on its own reads as parser loss that is not there.
- Raw HTML in Markdown is passed through instead of escaped. The Markdown renderer's
html_passthrough_modenow defaults topass-through. Escaping was described as a security posture, but it was not the one doing the work: in thehtml → markdowndirection — the direction where untrusted input actually arrives — the HTML parser maps tags to AST nodes and drops<script>,<iframe>,<form>,<object>,<embed>and<svg onload>outright, so they never reach a renderer to be escaped. TheHTMLBlockandHTMLInlinenodes the Markdown renderer actually saw came from the Markdown parser: HTML an author wrote in their own Markdown, which Markdown permits by design and which any downstream renderer would have rendered from the original file anyway. Escaping it brokemarkdown → markdownfor no gain — our ownREADME.mdlost the contents of all eleven<details>sections and scored 97; it now scores 100 with no deltas, as do all six tracked root documents, and the CI fidelity gate is re-recorded from 97 to 100. Converting untrusted Markdown is the case that wants the old behaviour: passhtml_passthrough_mode="escape"(or--markdown-html-passthrough-mode escape). Only the Markdown renderer changes; the HTML, AsciiDoc and Textile renderers still default toescape(#178). - PyMuPDF is imported under its own name, not the deprecated
fitzalias. Some PyMuPDF releases emit aDeprecationWarningonimport fitz, which reached anyone running the CLI as noise about a dependency they did not choose and could not act on.pymupdfhas been the real module name since 1.24.3 and we require 1.27.2, so nothing is lost: 149 references across 15 modules now use it. No public API changes — thefitzmodule object is still what PyMuPDF hands back, and_pdf_layout.pystill neutralises the layout hook on both module objects, since the alias can be a distinct object in some installs (#284). AttachmentOptionsMixinandCloneFrozenMixinare no longer part of the public API. They remain importable fromall2md.optionsand nothing about them has changed; they are simply no longer listed in__all__, because exporting a mixin promises its shape to anyone who subclasses it and that is a larger commitment than an options dataclass. Onlyfrom all2md.options import *is affected.- A dropped keyword argument is now diagnosed by which mistake it was. A name that is an
option of no format still reports as
Unrecognized keyword arguments were ignored— the typo case #273 was about. A name that is a real all2md option the conversion's formats simply have no field for (pageson a markdown parse,flavoron an HTML render) now says so instead of telling the caller to check for a misspelling they did not make. Both can arrive from one call, as two warnings.to_astandfrom_astpreviously stayed silent for the second case whileto_markdownwarned; they now agree, which closes the last gap #273 left open. Callers who pass a real-but-inapplicable option toto_astwill see a warning where they saw none — it is telling them the option did nothing.
-
A withdrawn PMC article no longer takes the whole born-digital lane down with it. The committed manifest pins each article's SHA-256, which guarantees what the bytes are but says nothing about whether they are still served — and PMC reprocesses articles and drops the superseded version.
PMC11000011.1was fetched successfully one morning and 404'd that same evening, from two networks, with.2and.3equally absent (#329). The lane's only response was to abort the load, so a single upstream withdrawal made the corpus unscoreable on any machine without a warm cache, and the monthly scheduled run would have begun failing regardless of anything in this repository. A 404 is now recorded rather than raised: the article lands inCorpusSnapshot.unavailable, the snapshot stops reporting itself ascomplete, and the ids appear in the payload ascorpus.articles_unavailable(payload schema 3) and in the CLI summary beside the article count. That last part is the point — a score over 65 of 66 articles is a different measurement from one over 66, and nothing downstream could previously tell. Tolerance is capped at a tenth of the selection, and losing every article is spelled out separately so a one-article run cannot pass having scored nothing. Transient failures and digest mismatches are still fatal, because those are claims about the bytes rather than about the object's existence. -
The PDF optimization page no longer compares two different corpora. It led with "reduced the corpus benchmark from 21.4 minutes to 6.7 minutes — a 3.2x improvement", taken from the two committed reference runs' corpus-wide totals. The arithmetic is right and the comparison is not: the runs cover 160 and 149 documents with 115 in common, and the
arxivoverlap is zero — that source samples the most recent cs.CL submissions and the runs are nine days apart. Documents present in only one run account for 46% of the baseline's wall time and 86% of the optimized run's, so the headline measured a change of corpus as much as a change of code. The same applies to the PDF percentile rows: both runs have 80 PDFs, 30 of them different papers. The page now leads with the 115 shared documents: 11.5 minutes to 55.4 seconds, a 12.4x improvement — a defensible figure, and a larger one than the number it replaces. Thegovdocs1rows (p50 30x, mean 12.7x) and the000887.pdfcase study (10.7x) were already sound, because that shard is fixed, and they remain. -
Three places called the corpus sample "deterministic" or "reproducible" when two of its four sources are neither.
corpus.tomlhas markedarxivandpoireproducible = falseall along, andbenchmarks/corpus/README.mdcontradicted itself between its opening line and its own reproducibility section. Roughly 60 of 160 documents can differ between runs, which is exactly the trap the entry above fell into. -
The born-digital benchmark workflow no longer discards the scorer's exit status. It pipes into
teewithoutset -o pipefail, soteesupplied the exit code and a traceback in the summary file went green. The lane is ungated on fidelity by design; that was never a reason to lose a hard crash as well. -
report-fail-underis a measurement again instead of a constant. The GitHub Action advertises it as "fail if any document's conversion confidence falls below this score", and for most formats it could not fail at any threshold.all2md reportreturns a hardcoded score of 100 bandednot_assessedwhenever no detector ran for the document's producer — which is every markdown, text, docx, pptx and html input, PDF being the only richly instrumented format.all2md.confidencenames this "a vacuous 100 that means 'no detector ran', not 'verified clean'"; the gate readscoreand discardedband, so an empty file, a valid file and a deliberately broken file all passed--report-fail-under 100identically. The gate now reads the band. An unassessed document is neither a pass nor a failure: it is reported as not assessed, excluded from the threshold comparison, and never rendered as a bare 100. If a threshold was set and nothing could be assessed, the run is a configuration error — the same class as an empty glob, and exit 2 rather than 1, so it cannot read as "your documents failed". A mixed corpus keeps working; only a wholly unassessable one is refused. This repo's own CI was one of the affected consumers:--report-fail-under 100over*.mdhas been dropped from the docs gate, because Markdown can never satisfy it honestly. Fidelity remains gated at 100. -
The Semgrep security scan actually scans again. It had been reporting success without reading a single rule since 2026-07-01.
semgrep/semgrep-action@v1pins semgrep 1.36.0, which cannot parse registry rules carryingseverity: MEDIUM, so every run died onValueError: invalid rule severity value: MEDIUM— and the action wrapper swallowed the non-zero exit. The last genuine scan was 2026-06-30 (Found 0 findings from 1059 rules, 57 seconds); every run after it finished in 11–16 seconds having loaded nothing. BecauseSecurity Scan (Semgrep)is both a required check onmainand a release gate, v1.11.0 published through a gate that was structurally incapable of failing.SEMGREP_APP_TOKENwas never set either, so the old comment's reasoning about a cloud ruleset described a token-authenticated scan this job had never performed. The CLI now runs directly against the pinnedp/pythonandp/security-auditrulesets, which needs no token. A positive control runs first: semgrep is pointed at a deliberately vulnerable file written at run time, and the step fails if it reports no findings. That is the specific failure that went unnoticed for six weeks — a scanner that finds nothing because it loaded nothing looks exactly like a clean repository. The first working run scanned 1241 files with 346 rules and reported 18 findings, of which 2 are in shipped code: both the deliberateautoescape=Falsein the Jinja renderer, which emits Markdown rather than HTML. That line already carried anosemgrepfor one rule id; the new ruleset flags it under a second, which nothing could have noticed while the job was crash-passing. The remaining 16 are intests/,benchmarks/andstubs/— none of which the wheel ships — so the blocking scan is scoped tosrc/and those are triaged separately rather than blocking a release. -
The two external ground-truth lanes no longer disagree about which dimensions may support a verdict.
block_structure_similaritycompares block-category sequences without ever inspecting the text underneath, so content-free output scores 1.0 on it (issue #256). The born-digital lane measured that directly — against deliberately damaged output it rises when half the emitted content is deleted, and it separates own-page from wrong-page output by only ~0.06 — and refused to gate on it. The scanned-page ratchet went on comparing it against a tolerance every month. One project cannot hold both positions, and the one backed by measurement is the refusal, so the declaration now lives in one sharedbenchmarks/omnidocbench/dimensions.pythat both lanes read. Not gated is not unchecked: a run whoseblock_structure_similarityis missing, malformed, out of range, or inconsistent with its own recorded page scores is still red. Only the comparison of its value against the baseline is skipped, because a number that moves the wrong way under damage cannot be evidence of a regression or of an improvement. The gate now prints aNOT GATEDline naming the dimension and why on every run, pass or fail — a gate that quietly stops comparing something reads exactly like one that compared it and found it fine. -
python -m all2md.cliruns the CLI instead of refusing to start.python -m all2mdworked; theclipackage had no__main__.py, so Python answered'all2md.cli' is a package and cannot be directly executed. That fails before argparse sees the arguments, which makes it worse than an ordinary unknown-command error: every invocation fails identically no matter what follows it, so anything checking whether the CLI accepts a given flag gets the same answer for a real flag and an invented one. Both entry points now reach the samemain(), and tests assert they can still reject an unknown flag, since an entry point that cannot start looks exactly like one that accepts everything. -
Seven CLI flags the documentation named do not exist, and now the docs say what does. Most followed one rule the docs had backwards: a boolean option generates only the flag that changes its default, so
require_https=Trueyields--html-network-no-require-httpsand there is no positive form to type. Documented as--html-network-require-https, and worse, as "Default: Disabled" when HTTPS is in fact required by default — the one entry where believing the docs would have left someone thinking transport security was off when it was on.--html-network-max-remote-asset-byteswas a renamed option still documented under its old name and its old value (the real one is--html-max-asset-size-bytes, 50MB not 20MB).--markdown-page-separator-templatewas filed under Markdown but belongs to the paginated parsers, which its owndocument.pdfexamples already showed. Also corrected:--pptx-slide-numbers→--pptx-include-slide-numbers,--pdf-detect-columns→--pdf-no-detect-columns,--output-type→--output-format, and--rich-word-wrap, which never existed alongside the--rich-no-word-wrapdocumented directly beneath it. A test now fails if any prose page names a flag the CLI will not accept. -
Table captions survive a Markdown round trip. Markdown has no caption syntax, so the renderer demoted
Table.captionto an italic paragraph — which a reader could see, but which came back as ordinary prose, so the trip lost the caption and gained aParagraphnode that was not in the input. The caption is now written as that same italic paragraph followed by an<!-- all2md:table-caption -->marker, which the Markdown parser folds back intoTable.captionalong with the paragraph. Readers still see the caption, the AST comes back with the node count it started with, and since the marker carries no copy of the text, editing the visible line edits the caption. The fold is deliberately narrow: a bare marker, a marker with no table after it, or an italic paragraph with no marker are all left alone.comment_mode="ignore"suppresses the marker, and the caption then degrades to the italic paragraph as before. This was the last of the four formats in #237 — asciidoc, rst and org already round-tripped their captions (#237). -
json_to_astno longer crashes on explicit JSON nulls. A producer outside this library routinely writesnullfor a field it has nothing to say about, and"children": [null],"content": nullor a nullmetadataobject reached the node constructors unchanged. The failure then surfaced much later, inside a renderer, as aTypeErrornaming nothing that would help. Null list and object fields now read as empty, null child entries are skipped, and a field that is structurally required — aHeadinglevel, aListordered flag — raises aValueErrornaming the node and the field instead of defaulting to something plausible. Thanks to @santhreal (#265). -
"Your PyMuPDF is too old" now says so instead of raising
TypeError(PDF). The version guard built its message by joining PyMuPDF's version tuple, which holds ints, so the one branch that exists to tell a user to upgrade crashed from inside itself and reported nothing useful. It also reconciles the minimum version, which had drifted to three different values: the runtime guard demanded 1.27.2 while the converter's declared dependency — the numberlist-formatsprints and dependency errors quote — still said 1.26.4. Both now read one constant. -
Two columns that start level are read left-to-right, not by a hairline (PDF). Blocks were ordered on
yalone, so on a page whose gutter is too narrow for column detection to split, whichever column's top edge happened to be a fraction of a point higher was read first — and that decided the order of the entire page. On page 16 ofPMC7500012.1the two columns begin aty=89.708191andy=89.702942, five thousandths of a point apart, and the references came out running 39–61 and then 19–38: a document that looks correct and is not. Blocks whose tops fall within a twentieth of the page's average line height are now treated as starting on the same row and ordered byx. That is a size-relative measure rather than a constant, since what reads as "level" scales with the type size, and it is deliberately small: it only reaches blocks that begin at effectively the same height, where theyorder was noise to begin with. Measured on the PMC born-digital corpus (117 pages), reading-order similarity rises from 0.779 to 0.785 mean and 0.821 to 0.835 median, with block structure and text content up slightly and tables and whole-article recall unchanged — no dimension regressed. -
<del>,<s>,<sup>,<sub>and<mark>survive a Markdown round trip. mistune hands raw inline HTML through untouched, so the Markdown parser produced looseHTMLInlinenodes rather than the AST node that already existed for the meaning; the defaulthtml_passthrough_mode="escape"then escaped them on the way back out anda <del>x</del> breturned asa <del>x</del> b. They now fold intoStrikethrough,Superscript,SubscriptandMarkthe way<u>/<ins>already folded intoUnderline, and render as~~x~~,^x^,~x~and==x==. The existing constraints are unchanged: a tag carrying attributes stays raw HTML, because the attributes hold information the node cannot, and an unmatched opener or stray closer stays raw rather than being guessed at. -
<mark>no longer disappears when parsing HTML. It was listed as an inline element but had no handler, so it fell through to the generic unwrap and the highlight was dropped outright —<mark>x</mark>became a barex. Unlike the Markdown side this was silent loss rather than escaping, so no round-trip text comparison would have noticed it; a golden snapshot had captured the damaged output as expected. Same gap<ins>had. -
PyMuPDF's layout advisory no longer lands inside the converted document (PDF). PyMuPDF prints
Consider using the pymupdf_layout package for a greatly improved page layout analysis.with a bareprint()— not a warning, not a log record — the first timefind_tables()runs in a process wherepymupdf.layoutis not installed. all2md writes converted documents to stdout, so the advisory arrived inside the document:all2md report.pdf > report.mdmade it line one of the markdown, and any pipeline reading all2md's stdout got it as content. This was the common case rather than an exotic one —pymupdf-layoutis deliberately excluded from theallextra over its Polyform Noncommercial license, so the plain and[all]installs both hit it. all2md now opts out through PyMuPDF's own entry point when it opens a PDF; it ships that package as thepdf_layoutextra and already reports its absence through its own dependency machinery, which writes to stderr. -
.tar.gz,.tar.bz2and.tar.xzare accepted on the command line again. Every read-side command collects inputs through one extension filter, and that filter comparedPath.suffix— which returns only the last dot-separated component — against the set of registered extensions.Path("bundle.tar.gz").suffixis".gz", which no converter declares, so the file was dropped before any converter saw it and the CLI reportedNo valid input files foundabout a file that was present, correctly named, and readable:to_markdown("bundle.tar.gz")had opened it all along. The split was exact — every single-part spelling (.tar,.tgz,.tbz2,.txz) worked and every two-part one failed. Matching now walks the tails ofPath.suffixeslongest-first, so.tar.gzwins where it applies whilereport.2024.pdfstill resolves to.pdf. This reachedall2md,grep,search,viewandwatchalike. The same rule now backs the interactivebatchtype table, so the extension it offers is the one its filter accepts. -
--zipprojects its namespaced options like every other path. The CLI carries options fully qualified —pdf.pages,markdown.flavor, and the subcommand sections' own settings such asview.dark— and flattens them against the format that will actually handle each file. Every path that writes to disk does that per input file;--ziphanded the whole namespaced dict toconvert(), which matched none of it and reported the leftovers as the user's typos:Unrecognized keyword arguments were ignored: ['view.no_wait', 'view.dark']. Nobody typed those — they are the[view]section's settings, and the function that exists to drop them was never called on this path. Projection is now per item, so a mixed batch appliespdf.*to the PDFs andhtml.*to the HTML rather than to everything, matching what the same files get when converted to disk. -
Packaging no longer warns the user about an
attachment_modeit injected itself.create_package_from_conversionsforcesattachment_mode="base64"so attachments stay in memory, then passed it toconvert(); for a source format with no such option (markdown, txt) it was dropped and reported as an unrecognized keyword argument. The user never typed it. An ordinaryall2md *.md --package out.zipsaid "Check the API documentation for valid parameter names" about a command line that was entirely valid. Library-internal call sites can now mark an option as their own, and the three that inject one — the CLI packager, the MCP server'sattachment_modefrom server config, andconvert'sflavorshorthand — do. Anattachment_modethe caller passed explicitly is still theirs and still warns. -
Text rescued from a rejected table region is dehyphenated like any other prose (PDF). When a detected table's grid turns out to be degenerate the region is emitted as a paragraph instead, and its text is recovered with
page.get_textbox()— raw extraction, which returns the glyphs with their printed line breaks intact. Every other route into the AST passes throughdehyphenate_blocks()first and this one did not, so a word broken across a line stayed broken:Coroman-anddelnever becameCoromandel, and the word appeared nowhere in the output at all, which no text-recall measure notices because both fragments are still present. This is a growing path rather than an edge case — the layout model over-predicts tables, and each over-prediction routes a whole region's prose through it; on the page this was found on the predicted region covered the entire page body. On that article, 32 line-break fragments become 0 and 8 words reappear. The join now reads identically to ordinary prose, including where the two agree to leave a hyphen alone: a capitalised continuation keeps it (Anglo-Saxon) and a digit continuation is never merged. -
A list marker set in a symbol font now starts a list (PDF). Whether a line opened with a marker was decided by finding the first top-level
Textnode, which is the wrong question in both directions. A bullet set in a symbol font carries that font's flags, so it arrives wrapped — italic flags makeEmphasis(Text("-"))— and the line has no top-levelTextat all: it read as empty and never started a list, so one born-digital article's bullets came out as plain paragraphs each beginning with a literal-. In the other direction, reading past the first node to find someTextanswers for the middle of the line, so a citation opening with a styled journal name,Nature 12. 45-67, reported a numbered marker and became a list item. Detection now descends into inline wrappers, and the marker is removed by character count so that it comes off even when it sits inside a wrapper or straddles two nodes. Reading the raw spans instead would not work, which is worth recording because it looks like the obvious fix: the parser rewrites four bullet glyphs (U+F0B7,U+00B7,U+2022,U+25CF) to-, and three of those four are not markers in their printed form, so detection has to run after that conversion rather than before it.A marker and the space that disambiguates it are also routinely separate spans, which is why Word's second-level Courier
obullets never became list items at all — the rule that anomust be followed by a space (so that "office" is not a bullet) could never fire, because the space was in the next node. Those bullets now nest under the item above them instead of landing as loose paragraphs between the items they belong to.That look-ahead is allowed only for a bullet, never for a number, and the restriction is the load-bearing part rather than a tidiness. A numbered marker arrives split exactly the same way —
Text("44.")thenText(" Konema, Nigeria …")— and nothing in a PDF distinguishes the 44th bibliography entry from the 44th item of a list. Reading across that boundary turned reference lists into ordered lists, and since nothing carries a start number through, the renderer printed them from 1: reference 44 came out as item 1 and no citation in the body could be matched to its reference. A numbered marker must therefore be complete within one span, as it always was.Measured on the born-digital corpus over the twelve list-bearing articles that complete locally, list-item recall rises 0.231 → 0.269 and precision 0.116 → 0.141; only three articles change at all. The items still missed are overwhelmingly ones the PDF prints with no marker of any kind, which no marker rule can reach.
-
A bulleted or numbered list is no longer collapsed into a single item (PDF). The vertical gap between two list items is the same gap that separates two paragraphs, so the paragraph-break rule is suspended once a list has started — otherwise every item that wrapped would be split at its own second line. Nothing was put in its place, so an item could only end when its block did, and a whole list arrived as one item: one born-digital article emitted 1 list item for its 16 bullets. A line carrying its own marker now starts the next item, whatever the spacing says. The rule is narrow on purpose — it applies only inside a paragraph already recognised as a list, so it cannot turn prose into one. Measured on the born-digital corpus, list-item recall rises 0.059 → 0.212 and precision 0.040 → 0.113.
-
A heading that wraps onto a second printed line is now one heading (PDF). A PDF has no notion of a wrapped heading — it has two lines of type — and each line reached the emitter on its own, so a long section heading became two sibling headings and an article title set on three lines became three
#s. None of them matched the real title, so the text was there and the structure was not. A heading line now continues the heading directly above it when the two are at the same level, nothing was emitted between them, and the second sits withinHEADING_WRAP_GAP_RATIOline heights of the first — the gap is a ratio rather than a point count so it means the same thing for a 24pt title and a 9pt subheading. Two headings that genuinely follow one another are separated by the space above a new section, which is what puts them beyond it, and a second line opening with its own numbering starts a new heading however tightly it is set. Measured on the born-digital corpus, section-title recall rises 0.729 → 0.766 and precision 0.507 → 0.623 — 231 headings emitted for 188 real titles, down from 270, so the join removes 39 duplicate headings as well as recovering 7 real ones. -
A lone math glyph is no longer promoted to a heading (PDF). Heading classification validated a candidate by font size, bold/all-caps requirement, maximum length and an internal-sentence-boundary check, and never asked whether the text contained a letter. Large delimiters are set in a symbol font well above body size, so they cleared the size gate and passed every remaining one — short, non-empty, no sentence boundary — and were emitted as headings from inside displayed equations. One chemistry paper in the born-digital corpus produced 179 headings for its 9 sections, 122 of them a single Private Use Area glyph (∑, ∫, large parentheses and brackets); it now emits 55, with its real headings untouched and its text content byte-identical once heading markers are stripped. The gate is
str.isalnum, not an ASCII character class, precisely so that headings in CJK, Cyrillic, Arabic, Devanagari, Greek and Hangul still qualify — an ASCII test would have deleted every one of them while fixing this. -
Section headings inside a multi-line block are no longer flattened to prose (PDF, requires
pymupdf-layout). Layout labels were assigned per block, by IoU against the model's predicted region. That is a fair test only when a block is one semantic unit, and PyMuPDF returns a whole journal column as a single block — so a two-line heading inside it scored an IoU near 0.03 against its own block and itssection-headerlabel was discarded before anything could use it. Measured on the born-digital corpus, 54% ofsection-headerpredictions never reached a block, the median miss being a block 38x the prediction's area; the same plumbing lost 52% oflist-itemand 51% ofpage-headerlabels. Labels are now also stamped per line, by containment rather than IoU — a line inside a correct region has a low IoU with it by construction — and the heading path consults the line's own label before the block's. Against JATS section titles on 12 articles, the share emitted as headings rose from 0.420 to 0.729 (79 → 137 of 188) for five additional headings not backed by a title. Two articles went from 0/32 and 3/33 to 24/32 and 26/33. Nothing else on the corpus moved: text recall is identical to the digit in both arms (95.6% of attainable overall;title94.2%,text_block96.3%,table96.7%), reading order is unchanged at 0.779, andblock_structure_similarityrises 0.548 → 0.567 — a change in structure and nothing else, which is what was intended. This is the same class of defect as the partial table region fixed above: a block is the wrong unit to judge, and the fix is to judge the line. -
The structural half of heading detection is now measured. The corpus lane scored
titleas text recall, which cannot distinguish## Materials and Methodsfrom a bare paragraph carrying the same words — a parser that flattened every heading in the corpus would have scored 1.00 on it. Note the ground truth for this is section titles specifically: the oracle'stitlekind also mapsarticle-title, which appears inside every bibliography<ref>, so scoring headings against it rewards exactly the wrong behaviour on a 60-reference article. -
A table region covering part of a text block no longer deletes the rest of that block. Blocks a table region covers are withheld from the text stream, because the region is emitted in its own right — as a table, or as a paragraph when the grid is rejected — and emitting both would duplicate it. That decision was made per block on a majority-area test, so a region covering more than half of a block removed the whole block. But "more than half" is not "all of it": PyMuPDF returns a full-height journal column as a single block, so a region predicted over its lower half cleared the bar for the entire column and the upper half was carried out with it — deleted outright, since the re-emitted region text does not include it. On page 16 of one benchmark article this silently removed nine reference entries, a region over y=380–733 of a column spanning y=90–733 taking 54.8% of it and everything above with it. Coverage is now judged per line: lines a region covers are still withheld, and whatever lies outside every region survives with its bounding box tightened to what remains, so reading order still sorts correctly against the table it precedes. A line straddling a region boundary is assigned by the same majority rule rather than duplicated or dropped by both sides. Lines that are blank or whitespace are not rescued: those border a table region routinely, and emitting them would replace a dropped block with an empty paragraph. Measured on 20 articles of the PMC born-digital corpus, title recall of attainable rose from 97.6% to 99.2%, and the four full-height column drops the corpus contained fell to two.
-
Words no longer run together where a bold or italic run wraps onto the next line. Lines of a paragraph are joined with a separator space unless the text already ends with whitespace there, and the check that decides this walks back to the last text leaf, which may sit inside a
Strong/Emphasis/Link. It conflated two different answers from a wrapper — "its text does not end in whitespace" and "it holds no text at all" — and treated both as keep looking. A line ending in a styled run therefore fell through to whatever preceded that run, and if that ended with a space the separator was suppressed and the two halves were concatenated:negotiating Roang on+Lamotrek Atollcame back asRoang onLamotrek. The two runs then looked adjacent to the inline consolidator, which merged them into a single node, so the space could not be recovered downstream either. The walk now distinguishes "no text here" from a definite verdict about the join point. This affects any wrapped styled run, not one document kind, but it surfaced through bibliography entries on the PMC born-digital corpus: a wrapped reference title is short enough that one bad join costs it more n-grams than a recall threshold allows, while a long paragraph absorbs the same damage unnoticed. Over a 20-article subset it recovers 16 of the 33 unrecoverable titles with none newly lost, takingtitlerecall of attainable from 95.4% to 97.6%; one review article with a large reference list goes from 22 lost to 6. The remaining losses are a separate defect — text genuinely absent from the output rather than reflowed — and are still open. -
Auto-mode OCR no longer discards a good text layer because the page has figures on it. In
mode="auto"the decision to OCR a page was made by summing the area of every image on it and comparing that toimage_area_threshold(then 0.5) — regardless of how much text the page already had. Sincepreserve_existing_textdefaults toFalse, a page that tripped this had its publisher-supplied text thrown away and re-read from a picture. On the PMC born-digital corpus, characterized as 0.0% scan-shaped, this fired on 20 pages across 11 of 66 articles; every one of those pages had real extracted text (median 536 characters, up to 1998). Two separate faults. Summing image areas does not measure whether a page is a scan: one affected page carried six figure panels covering a tenth of the page each, which summed past the threshold while nothing on the page was remotely page-sized. And the threshold sat far below where scans actually live. The trigger now measures the largest single image, and the default is 0.8. That boundary is calibrated rather than picked: across 101 scanned pages the largest image covers exactly 100% of every one, while across 851 born-digital pages it never passes 64% (median 13% of pages carrying any image at all). Note the threshold's meaning changed with its default, so an explicitimage_area_thresholdis now read against the largest image rather than the summed area. The narrowing was checked in both directions: a real scan raster carrying a thin text layer — a running header, which clearstext_thresholdso only this branch can reach it — still triggers OCR, and reverting the measure makes the born-digital pages fire again. Measured over the 11 affected articles:text_content_similaritymedian 0.515 → 0.599,block_structure_similaritymean 0.530 → 0.563, whole-article recall of attainable 94.6% → 95.1%, and 9 of the 11 articles stop OCR'ing entirely. Table scores andtitlerecall are unchanged, and the 55 unaffected articles are untouched by construction — exactly 16 of the corpus's 750 page decisions change, all of them from OCR to no-OCR. -
Borderless tables in layout-predicted regions are recovered instead of being emitted as prose. With layout analysis on (
pdf_layout), a region the layout model predicts to be a table was searched with PyMuPDF's defaultfind_tables()strategy, which requires ruling lines on both axes. Journal tables are typically booktabs-style — horizontal rules only, or none — so the search found nothing and the region was demoted to a paragraph. On the PMC born-digital corpus that was 0 of 31 such regions recovered. The parser's ownlayout_region_not_tabulartelemetry made this look like guards rejecting tables they had found; instrumenting the branch showed no grid was ever found to reject, and that the event also fired a second time on regions whose specific rejection reason had already been recorded, double-charging the confidence score. Text-alignment detection is now tried as a fallback, and the duplicate event is gone. Because that fallback has no ruling lines corroborating it and the layout model over-fires, it is held to one extra test the line strategies are not: its columns must not cut through words. Without it, a mis-predicted region rendered a page of abstract prose as a seven-column table of half-words (study was condu | cted to explore) — every table metric improved while whole-article recall fell from 92.6% to 83.8%. Grid shape, reading-order preservation and region corroboration (ruling lines, aTable Ncaption) were each measured as guards and each failed to separate real tables from gridded prose; whole-word integrity, measured against the page's own unclipped word segmentation, separates them cleanly. Net effect on a 12-article subset: tables emitted 4 → 12 of 32 expected,table_content_similaritymean 0.075 → 0.241,table_structure_similarity0.091 → 0.235, with whole-article recall unchanged at baseline. -
Scanned PDFs now keep their block structure instead of collapsing to one paragraph. Every OCR'd page previously projected as exactly one
Paragraph— noHeading, noTable, no list structure — because the OCR result was returned as a flat string and wrapped in a single synthetic block spanning the whole page, with one line, one span and a hardcoded font size. Everything downstream of OCR segments on block, line and span geometry, so column detection, header/footer trimming, table-region filtering and block-to-node conversion all received one page-sized rectangle and had nothing left to work with. Measured across all nine OmniDocBench data sources: 33 semantic blocks over 36 pages against 704 annotated regions. It was specific to the OCR path — under the same parser policy, born-digital PDFs emit 7–20 blocks per page with real headings and tables. Tesseract already assigns block, paragraph and line numbers and per-word boxes;image_to_stringdiscards them andimage_to_datareports them, so OCR now returns paragraphs mapped back into PDF points and the parser emits one block each. Engines that cannot report layout, and any failure recovering it, fall back to the previous behaviour rather than losing the page. -
Scanned multi-column pages no longer interleave their columns. Sorting blocks by vertical position assumes a column is one top-to-bottom run. OCR paragraph boxes are tight to their glyphs rather than the wide regular blocks column detection looks for, so a two-column scan was read as one column and the sort then alternated left and right fragments — turning a reference list into "Norlund / 1997. Occupational / NRC / Sluiter". The OCR engine already emits blocks in reading order across columns, so that order is now preserved rather than rebuilt from geometry. Both fixes are scoped to OCR-derived blocks; PDFs with a text layer are unaffected, and tests pin that.
-
Scanned pages now carry usable positions. Because paragraphs are real blocks, each one reaches the AST with its own
SourceLocation.metadata['bbox']. A scanned page went from a single box covering the entire page to 54 distinct ones, so a citation into an OCR'd document can resolve to a region rather than to "somewhere on this page".
1.11.0 - 2026-08-04
- Tests: generative round-trip fuzzing across the format matrix.
tests/document_strategies.pybuilds arbitraryDocumenttrees with Hypothesis and feeds them toroundtrip_reportfor all 24 round-trippable formats, behind four gates: every format must be classified (so a newly registered one cannot silently skip the fuzzer),astmust score exactly 100 as the control, no format may raise outside a known-crashes allowlist, and shapes drawn from previously fixed defects must survive the trip. Both allowlists arexfail(strict=True), so they can only shrink — fixing an entry turns it into an XPASS and fails the build until it is removed. It found six crash classes and a set of invariant gaps on existing formats, now filed as issues. Thanks @santhreal (#204). - Tests: external PDF fidelity against pinned OmniDocBench ground truth.
benchmarks/omnidocbenchdownloads the immutable 981-page v1.0 corpus, callsall2md.to_astonce for each page, and compares AST text, tables, formulae, and reading order directly with annotation fields. Dimensions the parser cannot yet express are reported as unsupported instead of scored, so absent capabilities cannot earn credit. A fail-closed ratchet records score denominators, variance, and exact page scores, rejects corpus, oracle, parser-policy, parser-runtime, or conversion drift, and requires review for both regressions and improvements. The full lane runs only on a monthly schedule or manual dispatch. Pull request and release CI keep using synthetic, network-free tests. The first baseline is recorded: text content 0.5058, reading order 0.6034, block structure 0.1176 over all 981 pages. Exact reproduction of the annotation scores 1.0, and no degradation of it scores higher: checked across eleven degraded variants and all six dimensions that then existed, on all 981 pages, plus the variants that sweep did not cover — every one of its variants deleted blocks, so output that kept the block structure and destroyed the content, or kept both and only re-chunked, went untested. Both went on to matter. Blanking every block scored a perfect reading order, so a block now has to be located before it votes on the ordering; and pairing emitted blocks against annotated ones one-to-one scored segmentation rather than order, so that text reproduced exactly but emitted as a single block scored 0.0. The first full-corpus run put 894 of the 981 pages at exactly zero, 128 of them scoring 0.9 or better on text content. Blocks are now located inside the concatenated output, which is blind to how it was chunked, and the block-category sequence is reported on its own asblock_structure_similarity— segmentation is a real question and a different one from ordering. Read that dimension as a granularity ratio rather than a quality score: it is bounded by the ratio of the two block counts, it never inspects the text under a block, and it is independent of the other two (+0.03 and +0.05, where those two correlate +0.84). Thanks @santhreal. - The OmniDocBench lane records what its corpus actually contains.
provenance.corpus_characterizationcounts how many pages carry a text layer, vector drawings, or the full-page-image shape of a scan, and how many documents the parser ran OCR on. The lane was built, gated and baselined before anyone asked that question, and the answer changes what the scores mean: every page in the pinned corpus is a raster, so they grade OCR rather than the PDF text and table paths. Counted from the PDF directly rather than from a projection, so a parser change cannot alter what the corpus is reported to contain, and excluded from the gate's identity fields so evidence like this can be added without invalidating a recorded baseline. Every page of a document is measured, not just its first — a title page is not a sample of the article behind it — and the scan shape is decided by image area: a page counts as scanned when one image covers at least 80% of it. Calibrated against 49 pages of scanned journal back-catalogue, where the largest image covered exactly 100% of every page, and 101 pages of modern born-digital articles, where the largest figure reached 61%. The earlier rule of "exactly one image and no vector drawings" was wrong in both directions on that sample: it fired on born-digital pages carrying a single figure, and missed scans that ship a second small raster beside the page image. - An erased benchmark dimension no longer reads as a verdict on the parser.
unsupported_dimensionssaid "all2md emitted no Table nodes on N converted page(s)", which names one side of a two-sided fact. On this corpus it is the wrong side: every page is a full-page raster, so the PDF table path never runs and there is nothing for it to have missed. The message now states the parser's output, how many pages carry ground truth for the dimension, and how many of the characterized pages are full-page images, then points atprovenance.corpus_characterizationand leaves the cause to the reader (#257).
- A
strthat names a missing file now raises instead of becoming the document. Every text parser accepts astrthat may be either a file path or the document content, and nothing in the signature distinguishes them — so each parser guessed, and the library guessed two different ways. Fifteen parsers fell through to "it must be content", which meant a typo in a filename produced a one-line document containing the filename:MarkdownToAstConverter().parse("does_not_exist.md")returned aParagraphreadingdoes_not_exist.md, which downstream is a successful conversion of the wrong thing. The other three (csv,fb2,rtf) read everystras a path, so raw content was unusable. Both halves now go through one classifier inall2md.utils.inputs.resolve_str_input, so all 18 agree: raw content parses, and a string that looks like a path but does not resolve raisesFileNotFoundErrornaming the escape hatches. "Looks like a path" is deliberately narrow — no whitespace, no://, and ends with an extension all2md knows — so prose such as"read config.json"or"Visit https://example.com"is still content. Callers who pass content that fits that shape should passbytesorio.StringIO;Path(...)still always means a path.ini,json,tomlandyamlalso stop re-wrapping typed all2md failures asParsingError, so the more specific exception survives (#233). - Type inference no longer reads
1and0as booleans. Withtype_inference=True, the JSON, YAML and TOML renderers treated the table cells1and0astrue/false, so anagecolumn of1serialized as a boolean. They are now integers;true/yes/onandfalse/no/offstill infer as booleans, matching YAML 1.1. Output changes for anyone who relied on1/0inferring as booleans. Thanks @santhreal (#202).
-
Bold and italic no longer swallow the images, links and code they wrap. The inline consolidator asked
_has_nested_formattingwhether aStrong/Emphasisheld anything worth recursing into, and that check looked only for nestedStrong/Emphasis. A node wrapping anImage,LinkorCodetherefore looked like a plain-text node, and the fallthrough path rebuilt it from its concatenated text alone. AnImagecontributes no text, so****rebuilt to an emptyStrongand rendered as the empty string — the image and the paragraph around it both vanished, with no error anywhere. Any non-Textchild now counts, so the node is recursed into instead of rebuilt.Strikethrough,Mark,Underline,SuperscriptandSubscriptare consolidated recursively too, rather than passed through untouched. Thanks @santhreal (#264). -
HTML table cells keep their
colspan,rowspanand alignment. The parser read a cell's alignment only to build the table's column-alignment list and dropped the span attributes on the floor, so<td colspan="2">parsed as an ordinary cell and the merge no longer existed by the time anything rendered. All three now land on theTableCellitself, and a span that is missing, non-numeric or below 1 falls back to 1 rather than raising. Spans now survive HTML→HTML, DOCX and AsciiDoc round trips; Markdown has no span syntax, and its renderer still does not pad for a spanned cell, so acolspan="2"there continues to put the following cell in the next column. Thanks @santhreal (#266). -
A nested EPUB table of contents is no longer flattened to its top level. ebooklib returns a TOC as a tree, where a section with children is a
(Section, [children])tuple, and the builder iterated it one level deep — keeping entries that had a.titleand silently discarding every tuple. A book that nested its chapters under parts emitted the parts and nothing else. The TOC is now walked recursively, one heading level per level of depth, clamped at 6. Thanks @santhreal (#267). -
A Word or ODT heading deeper than level 6 no longer crashes the conversion.
Headingvalidates its level as 1–6 in__post_init__, but the DOCX parser passed through whatever number it matched in aHeading Nstyle name and the ODT parser whateveroutlinelevelsaid. Word ships built-in styles throughHeading 9, so an ordinary document raisedValueError: Heading level must be 1-6, got 7and converted not at all. Both parsers now clamp into range. Thanks @santhreal (#262, #263). -
A null path item or
componentssection no longer crashes an OpenAPI spec. YAML lets a key be written with nothing under it —/pets:orcomponents:on a line by itself — and that parses asNone, which is legal in a spec that is still being filled in. Both were then used as dictionaries (path_item.items(),components.get("schemas")) and raisedAttributeError. Non-dict path items are skipped, and a nullcomponentsnow falls back to Swagger 2.0'sdefinitionsthe same way a missing one always did. Thanks @santhreal (#260, #269). -
An escaped pipe in a PDF table cell no longer splits the cell or doubles its backslash. The markdown-table fallback in the PDF parser split rows on every
|, so a cell containing\|fractured into two cells. Escaped pipes are now masked before the split and restored as a bare|— the AST holds unescaped text and the Markdown renderer adds the backslash back on the way out, so restoring the backslash here as well would have escaped it twice and emitted\\|. Thanks @santhreal (#268). -
Table captions survive a round trip in AsciiDoc, RST and Org. All three have a caption syntax and none of them used it in both directions. AsciiDoc already wrote the caption as a
.My captionblock title, and its parser ignored the line, so the caption made the trip out and not the trip in. RST emitted a bare grid with nowhere to put one; it now uses the.. table::directive, which takes the caption as its argument and the table as its indented body, and reads it back. Org emitted nothing; it now writes the#+CAPTION:affiliated keyword. Doing that also narrowed the filter that keeps#+TITLE:and friends out of the body text, which previously dropped every#+KEYWORD:line and would have eaten the caption with them — it now works from a list of genuinely document-level keywords. One visible consequence: an RST table caption that used to vanish on conversion now reaches the output, so RST→Markdown gains an italic line above the table. Markdown has no caption syntax and its arm of this is still open (#237). -
AsciiDoc and Org: an ordered list keeps the number it starts at. Both formats renumber a list themselves, so
1.throughn.is all either one preserved and aList(start=5)came back asstart=1. The two lost it at opposite ends. AsciiDoc emitted no marker at all; it now writes the[start=N]block attribute, and the parser reads it (and consumes it, so it cannot attach to a later list). Org did emit the number — a literal5.— but Org renumbers from 1 unless the first item carries an explicit counter set, so the information was in the document and thrown away by Org and by our own parser alike. The renderer now adds[@5]to the first item, and the parser reads both[@N]and, failing that, the list's own first number, which is what the Markdown parser already did. Hand-written5. Firstin Org therefore starts at 5 now (#239). -
RST: all six heading levels are now distinguishable. Two defects collapsed them, and both are visible on any document with more than a couple of heading levels. The renderer had five underline characters for six levels, so levels 5 and 6 shared
*— and RST derives a heading's level from the order in which each underline character first appears, which makes two levels sharing a character the same level. The default gains a sixth character ("), and a repeated character is now rejected byRstRendererOptionsrather than silently merging two levels. Separately, on the parsing side, docutils was promoting a lone top-level section's title to the document title and a lone subsection's to the subtitle. That takes both out of the section tree, so the depth count restarted underneath them: a document readingTitlethenSectioncame back with both at level 1, and six properly nested headings came back[1, 2, 1, 2, 3, 4]. Sections stay sections now (doctitle_xformoff, as Sphinx does), so the level is the nesting depth. Bibliographic fields written under a title are still read as metadata — docutils only lifts them into adocinfonode when it promotes the title, so they are now read from the field list directly (#238). -
AsciiDoc: heading levels no longer shift, and level 6 is no longer dropped. The renderer added one
=to every level, reserving a bare=for a document title it never actually wrote. Nothing read it back that way — the parser counts=and returns that number — so every heading came back one level deeper than it went in. Level 6 was worse than shifted: it rendered as seven=, which is not a heading at any level, so the node was dropped and six headings became five. AsciiDoc has exactly six markers for six levels, so all six only fit if level 1 is a single=— which is what the renderer's own docstring already claimed it did. Output changes: a level-1 heading is now= Titlerather than== Title(#236). -
AsciiDoc: a table no longer gains a phantom column. Rows were written with a trailing delimiter —
|A |B |— and AsciiDoc reads whatever follows the final|as one more cell, so every N-column table parsed back as N+1. It was silent and it did not depend on the contents: a one-column table came back with two. The extra column also persisted into anything converted onward from that AsciiDoc. Each cell is now introduced by its own delimiter and the row ends with the last cell's content. Fixing the renderer exposed the matching parser gap: a span spec belongs to the cell after it, so canonical AsciiDoc writes|A 2+|B, and the parser only recognized the spec when it stood alone in its own segment — the form the old renderer happened to emit. Hand-written|A 2+|Bsilently lost the span. Both forms parse now. Output changes for anyone diffing rendered AsciiDoc tables (#235). -
Org: a source block above the first heading is no longer deleted. Text before the first
*heading went through a filter meant to keep file properties such as#+TITLE:out of the body, and it dropped every line starting with#+. Org spells its block delimiters with the same prefix, so#+BEGIN_SRC/#+END_SRCwere removed and the code between them re-flowed as a paragraph — an org file that opens with a code block silently lost it, while the identical block one line below a heading parsed correctly. Only#+KEYWORD:lines are filtered now, and never inside a block, so a#+TITLE:written in Org source stays source.#+BEGIN_QUOTEand#+BEGIN_EXAMPLEare also recognized for the first time — in both positions, since neither was ever handled: they used to reach the output as literal text, mangled on the way, because+…+is Org's strikethrough syntax and#+BEGIN_QUOTEtherefore rendered as#~~BEGIN_QUOTE. A quote block becomes a block quote, an example block a fence with no language, and any block kind still unrecognized contributes its contents without its delimiters. Also: a bullet with nothing after it is now a list item. The item pattern required content, so- \n- bparsed as a one-item list (#240). -
AsciiDoc: nested lists survive a round trip. The renderer emitted a
+list continuation before a nested list, which detaches it into a block of its own and leaves the**marker with no*parent at the level below it — output the AsciiDoc parser rejected withCannot nest to level 2 without a parent item at level 1. Every document with a nested list was affected, not only the task-list shape the fuzzer first reported. Nesting is now carried by the marker alone;+is still emitted for code blocks, tables, block quotes and additional paragraphs, which do need it. Closing the round trip also required the parser to learn list continuations at all: it treated a lone+as the end of the list, so the item after an attached block started a fresh list with no parent to nest under, and valid hand-written AsciiDoc such as* a\n** b\n+\nc\n** dwas rejected. Continuation blocks now attach to the open item and the list stays open (#206). -
Rendering failures now always raise
All2MdError, whatever the underlying library raised.from_astdocuments that failures surface asAll2MdErrorsubclasses, but renderers delegate to third-party libraries that raise their own types, and only some renderers translated them —from_ast(doc, "pptx")raised a bareValueErrorandfrom_ast(doc, "rtf")a bareKeyError, so a caller writing the documentedexcept All2MdErrorcrashed instead. Probing the whole matrix showed 15 of the 24 formats leaked, not just the two the fuzzer happened to expose, so the translation now happens at the single point every render passes through; a renderer that already raisesAll2MdErrorkeeps its own message. The PPTX renderer, which guarded only its save step, and the RTF renderer, whoserender_to_stringwas unguarded whilerenderwas not, are both brought in line with the DOCX renderer (#212). -
HTML parser: nested tables no longer duplicate their cells into the outer row. Cells were collected with a recursive
find_all, so a table inside a<td>had its owntd/thpulled into the enclosing row as well — a one-cell inner table turned| inner |into| inner | inner |. Only direct children are collected now, matching how the row walk already worked. Thanks @santhreal (#199). -
DokuWiki parser: a whole-line
<del>,<sub>or<sup>keeps its formatting. Those tags were taken as a plugin or HTML block when they were the entire line, so with plugin parsing off the content was dropped outright. They now fall through to inline parsing and produce strikethrough/subscript/superscript. Thanks @santhreal (#200). -
Org parser: a line that is only
+strikethrough+is no longer an empty list. The list check matched-,+or*at the start of a line without requiring the space that follows a real bullet, so+gone+on its own line parsed as an empty list and lost the text. A space is now required after the marker. Thanks @santhreal (#201). -
AsciiDoc parser:
////opens a block comment instead of commenting out one line. The lexer checked//before block delimiters, so////was read as a line comment and the block's body was parsed as ordinary content — text meant to be hidden appeared in the output as paragraphs, and the closing////was consumed as another comment.////is now a delimiter pair whose body becomes a singleCommentnode, dropped entirely understrip_comments. Thanks @santhreal (#203). -
Textile renderer: an empty table no longer crashes the render. A
Tablewith no header and no rows appends nothing, so the trailing-newline cleanup indexed an empty buffer and raisedIndexErrorwhenever such a table was the first thing rendered. The buffer is checked before indexing. Thanks @santhreal (#205). -
ODT/ODP renderers: a nested link no longer fails the whole conversion. ODF forbids
<text:a>inside<text:a>and odfpy enforces it, so aLinkinside aLinkraisedIllegalChildand aborted the render. This was reachable from ordinary input rather than only from a hand-built AST: browsers accept nested<a>and the HTML parser preserves the nesting, soall2md page.html --out page.odtfailed outright on any page containing one. The inner link is now unwrapped — its text is kept inside the enclosing hyperlink and only its own target is dropped. Unwrapping rather than splitting the outer link into siblings, which is what a browser does, keeps the surrounding inline flow intact and stays correct at any depth, including a link buried under aStrongthat could not be split out without discarding the emphasis (#211). -
Table cells whose spans overflow or collide no longer crash the render or vanish from it. A
colspan/rowspanwider than the row it sits in is ordinary in real-world HTML, but every renderer sized the grid by summing each row's colspans, which ignores the columns an earlierrowspanalready consumes. Cells displaced past that width were dropped, and a span reaching into ground another cell held produced an impossible merge: DOCX raisedno `tc` element at grid_offset, PPTXrange contains one or more merged cells. The fuzzer reported the two crashes, but probing the whole matrix found the same geometry in eight renderers — the other six silently lost a cell instead, which is worse for being quiet. Grid layout now happens once, inBaseRenderer, under two rules: a span truncates rather than overlapping, and the grid widens rather than dropping a cell. Losing a merge is recoverable, losing content is not. DOCX, PPTX, LaTeX, ODT, ODP, Org, RST and PDF all take the shared pass (#207, #208). -
RTF: nested lists no longer crash the round trip, and stop losing their deepest items. The RTF renderer builds a pyth document, and pyth writes one
\ilvllevel prefix per paragraph inside a list entry — so an entry whose content was a nested list got the outer level's prefix and the inner level's prefix on the same output paragraph. Reading that back, pyth charges both controls to the preceding paragraph and sees a level decrease it never saw a matching increase for, at which point it pops the document off its own stack (IndexError, #209). Carrying a task status took a different path:ListsubclassesParagraphin pyth, so the marker run was inserted into the sub-list's content, where only entries belong, and the writer then dispatched on the bare string it found there (KeyError, #210). Nested lists are now flattened before being handed to pyth. That gives up nesting depth, which the RTF parser never reconstructed anyway — a plain two-item list already read back as two paragraphs — and in exchange it fixes a data-loss bug neither issue had noticed: a list three levels deep silently dropped its deepest item, because pyth pushed a list for the level increase and never re-attached it (#209, #210). -
to_ast()andfrom_ast()now warn about keyword arguments they don't recognise.to_markdown()has always warned; the other two dropped unknown kwargs at alogger.debugcall nobody sees. The result looked fine, which is the bad combination:to_ast(content, filename="x.md")is a natural thing to write,filenameis not a parameter of anything (the real one issource_format), so the hint was discarded, detection fell through to the plaintext parser, and the caller got back aDocumentof bareParagraphs — headings, lists and tables all gone, with no error. The same applied to any misspelled option name. All three entry points now route through one helper, so they cannot drift apart again. Two details worth knowing: the warning fires only for names that are not options of any format, because a real option that this particular format ignores is usually the library's own doing — the CLI packager forcesattachment_mode, the MCP server sets it from server config, andconvertinjects itsflavorshorthand — and blaming the caller for those would be wrong. And the existing warning pointed one frame past the caller, landing on<sys>:0, which made it useless to act on and collapsed every such warning into a single dedup entry; it now names the calling line (#273). -
An HTML comment stays a comment when rendering Markdown.
comment_modedefaulted to"blockquote", so<!-- note -->— valid Markdown, and invisible when rendered — came out as a visible> note, and an inline comment was glued into the middle of a sentence as[Comment by Reviewer: ...]. That is a content-fidelity bug rather than a formatting preference: the round trip added text a reader can see, and it was not even reversible, since the blockquote reads back as aBlockQuotenode rather than aComment."html"is the only mode that is a fixed point, and it is what every other renderer already does — each defaults to its own native comment syntax, and textile, which offers the identical three choices, already defaulted to"html". Markdown was the lone outlier. Found by our own quality gate: it is what blocked adding anmcp-nameregistry marker toREADME.md, which dropped the README's round-trip fidelity from 97 to 96 and failed the build (#271, #272).One visible consequence to know about: converting a DOCX with reviewer comments to Markdown now emits
<!-- Comment ... -->rather than a bracketed[Comment by ...]glued to the end of the annotated sentence, so the annotations no longer show up in the rendered prose. They are still there, and in fact carry more than before — the HTML form keeps the author and the timestamp, where the visible form dropped the timestamp. Passcomment_mode="blockquote"(or--markdown-comment-mode blockquote) to get the old, reader-visible output back; that is the case the previous default was chosen for, and it is now opt-in rather than the default for every Markdown document.
1.10.1 - 2026-07-27
A maintenance release. Almost all of it is infrastructure — the harnesses this project already had are now wired to CI as blocking gates — but wiring them up surfaced one real conversion bug, which is the reason to take the release.
-
GitHub Action: a conversion-quality gate.
action.ymlat the repository root ships all2md as a reusable action that scores every matched document and fails the build when fidelity degrades — the same ratchet this project runs on itself, pointed outward. It wrapsroundtrip --fail-underandreport --fail-under, and gates on the worst score across the matched set.- uses: thomas-villani/all2md@v1.10.1 with: paths: docs/**/*.md roundtrip-fail-under: 97
It refuses to pass quietly, which is the whole point: a
pathsglob that matches nothing, a run with no threshold set, and a document that cannot be converted at all are each a failure rather than a silent green. Config errors exit2, quality failures exit1. It also warns when a threshold sits ten or more points below your documents' real scores — documents that convert well score 99-100, so a threshold that sounds strict has enough dead headroom to never fire.The action lives in this repository rather than a separate one so
@v1.10.1installs all2md 1.10.1: the gate's verdict is the library's score, so the two versions must not drift. Seedocs/source/github_action.rst.
- Prose containing a
|is no longer swallowed into a table. mistune's_process_theadnever checks that the second line of a candidate table is a delimiter row — it matches each cell against the three alignment patterns and falls back to "no alignment" for anything else — so any line with the same number of pipe-separated cells as the line above it was accepted as the delimiter and then consumed. Two ordinary sentences that each happened to contain a pipe became a two-column table, and the second line was deleted outright. So did two fully-piped rows written without a delimiter, and two lines whose pipes sat inside code spans (whose backticks were mangled on the way through). A delimiter row is now required, per GFM, which is where the loss stops. Found by the new quality-gate action, which scored a table-freeCHANGELOG.mdas containing a table — the metric was right and the code was wrong (#177). - CI: the lint gate could not fail.
fix = truein[tool.ruff]applies to plainruff check, not justruff check --fix, so CI rewrote every auto-fixable violation inside the runner and exited 0 — green build, repair discarded with the runner, violation still onmain. Only rules with no auto-fix could ever turn it red. The setting is gone and CI passes--no-fix; a test pins both (#149). - Benchmarks: a failed corpus download is no longer cached as an empty result. An empty
_index.jsonreads back as a valid cache hit, so one transient network failure zeroed a source out permanently on any machine that keeps the cache. Found when a DNS blip reduced the corpus to 50 documents and the run still exited 0. - Benchmarks: the two large corpus archives (Enron, ~423 MB; govdocs1, ~250 MB) now get a bounded retry with backoff. Exhausting it still fails loudly — the retry buys tolerance for flakiness, never silence about failure — and a 404 is not retried at all (#182).
- Benchmarks: a corpus source that yields none of a format its
corpus.tomlentry requests now says so, instead of quietly covering a narrower format mix than the manifest advertises (#181). - Tests:
pytest -m unitworks again.tests/performance/conftest.pyoverwrotemarkexprinpytest_configure, silently discarding any-m— the documented fast path collected 7997 tests instead of 3857. CI never noticed because it passes no-m. - Tests:
TestSplitCLIE2Eno longer races under parallel workers. It used a fixed directory under the project root, so concurrent xdist workers collided in it and a crashed run left the directory behind. Thanks @rkfshakti (#185, #187).
-
Faster cold start:
import all2mdno longer loads every options module. Theall2md.optionspackage re-exports lazily (PEP 562) instead of importing all 30-odd submodules at package-init time. Becausefrom all2md.options.base import ...executes that package init first, any options import used to cost the whole set — which is how a bareimport all2mdended up loading 31 options modules to reach the two classesall2md/api.pyactually needs. Now 4. Total eagerly-importedall2mdmodules drops from 64 to 37. No API change:from all2md.options import PdfOptionsworks exactly as before, anddir()still lists every export.Measured on CI (median of six runner VMs, nine samples each):
import all2md230 ms → 162 ms (−29%) andall2md --version246 ms → 178 ms (−28%).--helpand a small conversion are roughly unchanged, as expected — both build the full parser or run the pipeline, so they load the options either way. The win lands on the per-invocation path that CLI and agent workflows actually pay. -
CI: the Markdown roundtrip fidelity benchmark (
benchmarks/roundtrip) now runs as a blocking gate on every push and PR, and is required before a release. AMarkdown -> AST -> Markdownregression that either oracle can see now fails the build instead of waiting to be noticed. Knowingly-accepted failures are declared inEXPECTED_FAILURES(currently one: raw HTML, which thehtml_passthrough_mode="escape"policy makes lossy by design); the gate also fails if such an entry starts passing or goes stale, so the list can't decay into a permanent excuse. -
CI: cold start is now guarded on every push and PR, and is required before a release. Two complementary gates, because the cost is milliseconds but the cause is an import graph:
tests/unit/test_eager_imports.pypins the exact set of modules a bareimport all2mdpulls in (deterministic, cannot flake), and aCold Start Gatejob compares wall-clock timings againstbenchmarks/startup_baseline.jsonwith a 20% tolerance. An unrecorded speedup also fails, so the baseline can't drift into describing code that no longer exists. The timing gate judges each scenario twice — raw milliseconds and milliseconds relative to the same run's bare interpreter — and goes red only when both agree, which is what separates a real regression from a runner that landed on a faster or slower CPU class. -
CI: a weekly
Corpus Fidelity Gate(also on dispatch) converts the corpus benchmark's reproducible half and compares the failure set — which documents fail, by name — againstbenchmarks/corpus/corpus_baseline.json. Only the two sources whose sample is fixed across cold runs are gated; arxiv and POI resolve against upstream state that moves, so a baseline would report document-mix churn as a regression. The recorded baseline converts 100/100 gated documents, so its accepted-failure list is empty. A short corpus is also a failure, which is what caught the download bug above. -
CI: a
Format Benchmarksjob converts the small per-format fixtures across the dozen formats the corpus gate cannot reach (it covers PDF and email only). It gates on conversion, not on time: these fixtures run in 5–885 ms, and gating a 5 ms measurement needs a variance study on these runners first. Timings are recorded to an artifact, which is what such a study would be built from. -
tests/performanceis kept but disarmed. Its seven absolute ceilings had 106×–1170× headroom on CI and its other seventeen assertions weremean_time > 0, true by construction — so nothing there could ever have failed, and nothing had ever run it, because a barepytest tests/deselectsbenchmark. What is checked now is that every format still converts to something non-empty: deterministic, and unable to flake. -
Docs:
performance.rstno longer reports throughput figures that came from no harness. The old table gave pages/sec, a unit the benchmarks have never produced, beside one row in MB/sec — measured and invented numbers side by side, which launders the invented ones. They are replaced by figures that name their source, and by the advice to measure your own corpus. It also documented PubMed Central as a live corpus source with runnable commands, though[sources.pmc]is commented out, and described the Apache POI sample as stable whenref = trunkis a moving branch. Adds the startup-cost section the page never had (#180). -
CI: bumped
actions/setup-python6 → 7 andactions/download-artifact7 → 8 (#188, #189). Created thedependencies,github-actionsandpythonlabels thatdependabot.ymlhad always referenced but that never existed on the repository, so Dependabot could not apply them.
1.10.0 - 2026-07-24
insert_modeMarkdown renderer option (--markdown-insert-mode): how to render insertions —markdown(^^text^^, the default, which round-trips),html(<ins>), orignore(#113).
-
OCR'd PDF pages with links no longer crash conversion. OCR-synthesized text spans omitted the
bboxkey that every real PyMuPDF span carries. On a page that was OCR'd and held a link annotation (common in signed documents), link resolution readspan["bbox"]and aborted the whole file withKeyError('bbox'). OCR spans now carry the page-rect bbox, so link resolution runs normally (and a page-sized span never spuriously matches a small link). -
Markdown tables with ragged rows no longer vanish. mistune rejects any body row whose cell count differs from the header's, and then discards the entire table and re-emits it as a literal paragraph — so one row with a missing or extra
|silently cost the whole table on the way to DOCX, PDF, ODT and every other format. The table rule is now GFM-compliant: short rows are padded with empty cells and cells past the header width are dropped, matching how GitHub renders the same source. Applies to tables nested in list items and blockquotes too. A header/delimiter width mismatch still correctly means "not a table." -
Binary renderers: soft line breaks render as spaces, not hard breaks. The DOCX, PDF, PPTX, ODT, ODP, RTF and CSV renderers rendered soft breaks (plain newlines inside a Markdown paragraph) as hard line breaks, so any hard-wrapped Markdown source produced output with a visible line break at every wrap point — 214 stray breaks in one real-world memo. Soft breaks now render as a space, matching the text renderers (HTML, LaTeX, RST, …) and CommonMark semantics; hard breaks are unchanged.
-
INI renderer:
DEFAULTsection no longer fails to render.configparserreservesDEFAULT, soadd_section("DEFAULT")raisesValueError— which meant any document whose keys landed in the default section (orphan key/value lists with no heading, or a heading literally namedDEFAULT) aborted the whole conversion with aRenderingError. Keys are now set on the parser directly for that section. Thanks @santhreal (#116). -
Org renderer: table header separators now end with a newline. The header rule and the first body row were emitted glued together (
|---|---|| 1 | 2 |), which is not a valid Org table and loses every body cell when re-parsed. Thanks @santhreal (#117). -
CSV renderer: line breaks inside table cells survive export.
LineBreaknodes and<br>HTML inlines were dropped when flattening a cell to text, silently joining the two lines. They now emit a newline, which the CSV writer quotes. Thanks @santhreal (#118). -
DokuWiki renderer: empty list items keep their bullet. An empty
ListItemproduced no output at all, so- a/-/- crendered as two bullets instead of three and shifted the list. Matches the Markdown/MediaWiki/AsciiDoc/RST renderers, which already preserve the blank bullet. Thanks @santhreal (#119). -
RTF renderer: lists nested in block quotes and definitions no longer crash. pyth's
ListsubclassesParagraph, so prefixing a paragraph rewrapped a nested list asParagraph(content=[ListEntry…])and raisedTypeError— any Markdown with a list inside a block quote failed to convert. Thanks @santhreal (#122). -
AsciiDoc parser: escaped braces in an attribute-ref shape no longer hang. Input as small as
{\{}spun forever: escape preprocessing left a{…}shape, the combined inline pattern matched at the cursor, every handler declined, and the fallback advanced by zero. An unclaimed match is now consumed as literal text. Thanks @santhreal (#123). -
AsciiDoc parser: cross-reference targets restore their escapes.
<<id>>and<<id,text>>skipped escape postprocessing that the siblinglink:branch already did, leaking internal placeholders into the URL and link text. Thanks @santhreal (#124). -
AsciiDoc parser: column spans survive a space after the pipe. The span regex ran against the stripped cell but sliced the unstripped one, so
| 2+| spans twoleft a stray+cell and split the row wrong. Thanks @santhreal (#130). -
AsciiDoc renderer: merged table cells keep their spans.
colspan/rowspanwere dropped entirely, so an HTML table with merged cells lost its structure on conversion even though the AsciiDoc parser already understood2+|,.3+|, and2.3+|. A leading cell's spec replaces the row-opening|, since a spec binds to the cell after the pipe it precedes. Thanks @santhreal (#131). -
MediaWiki parser: literal pipes in table cells survive. Cell-attribute stripping matched any cell whose text merely contained a
|, sopipe: a|bbecameband[[Target|label]]lost its target. A leading segment is now treated as attributes only when it contains=. Thanks @santhreal (#125). -
MediaWiki parser: table captions are read from
|+lines.|+was skipped outright, soTable.captionwas alwaysNoneand real wikitable captions were lost on parse and round-trip even though the renderer already emitted them. Thanks @santhreal (#132). -
MediaWiki renderer: table captions stay on one line. A caption containing a newline (common from HTML
<caption>) split across lines, which is invalid wikitable markup and dropped the remainder of the caption. Thanks @santhreal (#129). -
DokuWiki renderer: table column spans are emitted.
TableCell.colspanwas ignored, so merged columns were lost; DokuWiki expresses a span with empty continuation cells (| Wide || Tall |). Thanks @santhreal (#127). -
Textile renderer: block children of a list item are separated properly. Children were joined with a single space, so a table after a list item's lead paragraph became
* intro |_.H|— ordinary text to Textile, and the table vanished on round-trip. Thanks @santhreal (#126). -
AST serialization:
Comment,CommentInline, andMarknodes are supported. The dispatch tables had no entries for them, so serializing any document containing a Markdown HTML comment,==mark==, an AsciiDoc//comment, or an RST..comment raisedValueError: Unknown node type for serialization. Thanks @santhreal (#128). -
HTML parser:
<ins>is no longer silently dropped. It was listed as an inline element but had no handler, so it fell through to the generic unwrapping and lost its markup entirely — the counterpart<del>has always mapped toStrikethrough. It now parses to anUnderlinenode withsemantic="insert". This also recovers Textile's+inserted+, which python-textile renders as<ins>and which therefore used to arrive as unmarked plain text (#113). -
Markdown parser:
<u>and<ins>survive a Markdown round trip. They came back as rawHTMLInline, which the defaulthtml_passthrough_mode="escape"then turned into<u>on the next render. Both tags are now read back intoUnderlinenodes, so they round-trip losslessly. Tags carrying attributes, and unmatched or stray tags, are still passed through untouched rather than guessed at. The remaining inline tags (<del>,<sup>,<sub>,<mark>) still self-escape (#113). -
JSON and YAML renderers: duplicate table column names keep their values. Header text was mapped straight to dict keys, so a table with two
tagcolumns kept only the last cell of each row. Repeats are now suffixed (tag,tag_2) in both renderers. Thanks @santhreal (#137). -
LaTeX parser: section titles are read from the right argument. pylatexenc gives sectioning macros the argspec
*[{, and the parser took slot 0 — the*marker — as the title.\section*{Introduction}produced# *, and because slot 0 is empty for unstarred sections too, every heading fell through to a regex fallback that lost inline markup (\section{Hello \textbf{World}}gave# Hello \textbf\{World). Thanks @santhreal (#138). -
LaTeX parser:
\hlineno longer leaks into table cell text. Rule macros insidetabularwere converted as ordinary content, so a cell rendered as\hline c. Thanks @santhreal (#143). -
LaTeX parser:
itemize/enumerateitems keep their text. Only braced arguments on\itemwere read, but a LaTeX item body is the sibling nodes up to the next\item— so every bullet was empty and lists vanished from the output entirely. Item bodies are now collected from siblings, nested lists stay nested instead of being flattened away, and the source newline/indent after\itemis trimmed without disturbing spacing around inline markup. Thanks @santhreal (#142). -
LaTeX parser:
\chaptermaps to a heading. Onlysectionthroughsubparagraphwere routed to the sectioning handler, so with the defaultparse_custom_commands=Falseevery chapter of a book- or report-class document was dropped. Thanks @santhreal (#144). -
LaTeX parser:
\paragraph{...}titles are parsed as titles. Default pylatexenc has no macro spec for\paragraph, so the title stayed a sibling group and emitted an empty level-4 heading followed by loose text (#### \n\nTitle) — which broke round-trips, since the LaTeX renderer emits\paragraph{...}for level-4 headings. Inline markup in those titles now survives too. Thanks @santhreal (#145). -
HTML parser:
<tr>elements with no cells are skipped. An empty row became a zero-column header, rendering as invalid GFM (| |over||) and corrupting any table that followed it. A row with an empty<td>is still preserved. Thanks @santhreal (#146). -
Org parser: marker-only headlines are no longer dropped. orgparse reports
* TODOas an empty heading withtodo='TODO', and the parser discarded any headline whose text was empty — losing the headline and reparenting its body. The marker stays in metadata rather than the title, so* TODOround-trips as itself instead of* TODO TODO. A priority-only headline (* [#A]) is kept the same way. Thanks @santhreal (#147). -
Org parser: headlines with more than six stars no longer crash. Org allows any star depth, but
Headingaccepts levels 1-6, so******* Deepraised on parse. The level is now clamped at 6, matching the HTML and CHM parsers. Thanks @santhreal. -
CSV renderer: inline code, inline math and image alt text survive export.
Code,MathInlineandImageleaves were skipped when flattening a cell to text, so a cell holding`code`or an image came out as an empty field. Thanks @santhreal. -
MediaWiki parser: empty list items keep their bullet. A bare
*or#line produced no item at all, so the list came back short and every following item shifted up a position. Matches the DokuWiki renderer fix above. Thanks @santhreal. -
MediaWiki parser: attributes are stripped from
|+table captions. Cell attributes were already handled for|cells, but|+ style="..." | Captionkept the whole attribute segment in the caption text. Thanks @santhreal. -
BBCode parser: empty
[*]items keep their bullet. Same shape as the MediaWiki fix — an empty item was skipped, shortening the list and shifting the items after it. The segment before the first[*]is still correctly not an item. Thanks @santhreal. -
BBCode parser:
[color],[size]and[font]no longer flatten their contents. These tags have no Markdown equivalent, so they are stripped — but the strip discarded any markup nested inside them, and[color=red]see [b]this[/b][/color]lost its bold. The inner content is now parsed and spliced in place of the tag. Thanks @santhreal. -
FB2 parser: notes-body section titles are kept. The first heading of a notes body was dropped unconditionally to avoid duplicating the "Notes" heading, so a notes body with no body-level
<title>— or with an empty<title>placeholder — silently lost its first footnote heading. The leading heading is now dropped only when a body-level<title>actually produced one. Thanks @santhreal. -
FB2 parser:
<cite>is converted as a block quote. It was flattened into a plain paragraph while its structural twin<epigraph>became a quote, so a citation's block children collapsed into inline text. Both now take the epigraph path. Thanks @santhreal.
^^text^^and underline are now distinct in the AST.^^is pymdownx's insert extension, not underline, but both parsed to the sameUnderlinenode, so an insertion could not render as<ins>and a genuine underline emitted insert syntax.Underlinegained asemantic: Literal["underline", "insert"]discriminator (default"underline", omitted from serialized output when default, so existing JSON is unaffected). The HTML renderer emits<ins>for insert and<u>for underline (#113).underline_modenow defaults to"html"(<u>) instead of"markdown"(^^text^^). Markdown has no underline syntax of its own, and^^means insert;<u>now round-trips losslessly thanks to the parser fix above. Setunderline_mode="markdown"to keep the pre-1.10 spelling (#113).- CI: bumped
actions/setup-python6 → 7 andactions/setup-node6 → 7 (#120, #121).
- Relocked
torch2.12.1 → 2.13.0 (GHSA-rrmf-rvhw-rf47 / CVE-2025-3000, memory corruption intorch.jit.script), pullingtorchvision0.27.1 → 0.28.0. torch reaches us only through theocr-easyocrandsearchextras and all2md never callstorch.jit.script, so practical exposure was nil — but the lock pinned a vulnerable range for anyone installing those extras. - Relocked
setuptools81.0.0 → 83.0.0 (GHSA-h35f-9h28-mq5c / CVE-2026-59890,MANIFEST.inexclusions bypassable via a Unicode normalization collision on macOS APFS/HFS+). Transitive via torch only; all2md builds with hatchling.
1.9.0 - 2026-07-15
-
Conversion optimizer (
all2md optimize). Converts a document many times under different converter settings and reports the ones that recover the most well-formed structure — emitted both as a runnable command and as a.all2md.tomlsnippet. Built for the documents that need it most (the gnarly PDF with no known-good output to diff against), so the objective is reference-free — and it is deliberately neither of the two existing scores. The confidence score is a saturating breakage detector: on anything not visibly broken it pins to100regardless of settings, so it has no gradient to search (measured: 16 option combinations on a two-column PDF produced one distinct confidence score while the parsed AST produced four distinct outcomes). The round-trip score measures the renderer, not the parser — a garbled table round-trips through Markdown perfectly. Soall2md.optimizescores the parsed AST directly.Body text gates the score rather than contributing to it. Losing a paragraph is data loss; leaving a running header in is an annoyance, and the two are not interchangeable at any exchange rate — so a candidate's body-text retention multiplies its fitness (cubed), and no amount of tidiness buys back deleted content. The weighted dimensions are the ones that are genuinely tradeable: tables (scored as quality-weighted recall — filled cells discounted by shape regularity, so a hallucinated table earns almost nothing while a missed real one still costs its cells), structure, and cleanliness (how much repeated furniture the setting left behind, where furniture is content that repeats across a substantial fraction of the document's pages).
The search is cheap by construction — the named presets first, then coordinate descent, which costs
sum(len(values))conversions instead of a full grid'sprod(len(values)). It is still tens of full conversions, though, and a PDF page costs about a second to parse, so--sample-pagestunes against a slice of a long document and--cachemakes repeat runs nearly free (18.5s → 0.3s warm, on a 31-candidate run); the command warns up front when it is about to tune a whole document. Available from Python asall2md.optimize_options(source, ...)(withoptimizable_formats()), and from the command line asall2md optimize <file>(--rounds,--sample-pages,--no-presets,--top,--out,--json). Tunable formats:pdf,html,docx. The reported fitness ranks candidates against each other and is not an absolute quality score —all2md reportandall2md roundtripremain the scores for that. -
Round-trip fidelity scoring (
all2md roundtrip). Converts a document to another format, parses it straight back, and scores the structure that survived. Unlike the confidence report this comparison has a ground truth — the source document itself — so a lossless round trip scores exactly100and anything less is a concrete, itemized loss. Five dimensions are scored against independent alignments and combined:structure(0.40 — heading levels, list nesting, table placement),text(0.30 — the document-wide word stream),inline(0.15),tables(0.10) andreferences(0.05); dimensions the source does not exercise are dropped and the rest renormalized, so a document with no tables is neither rewarded nor punished for the tables it lacks. Alongside the score the report lists concreteStructuralDeltaincidents ("heading(h1) -> paragraph", "table 1: 4x3 -> 4x2", "3 of 229 words") so a low score is actionable. Tight/loose list items and nested-paragraph artifacts are normalized away, so format-legal spelling differences do not read as loss. Available from Python asall2md.roundtrip_report(source, via=...)(withroundtrippable_formats()listing the 24 validviaformats) and from the command line asall2md roundtrip <file>(aligned card by default,--viato pick the intermediate format,--json,--fail-under SCOREas a CI gate,--max-deltas N,--formatto override source detection for stdin). The score responds to converter options, which is what makes it usable as the fitness function for the plannedall2md optimize. -
Conversion confidence report ("quality card"). Every conversion now attaches a reference-free
ConfidenceReporttoDocument.metadata['confidence']— a0-100score, ahigh/medium/lowband, the signals behind it, and the discrete degraded-content incidents the converter recorded — surfacing sanity signals converters previously computed and threw away. PDF reports meaningful-text density (chars_per_page), OCR reliance (ocr_page_fraction), detected/rejected table counts (each rejected non-tabular region is recorded with its reason), and running-heading demotions; DOCX reports table/image counts and flags silently-dropped embedded objects, charts, and SmartArt. The singlescoredoubles as an optimizer fitness function (no ground-truth needed). Read it programmatically withall2md.confidence_report(source)or from the command line with the newall2md report <file>verb (aligned pretty card by default,--jsonfor machine use,--fail-under SCOREas a CI gate). Any parser can contribute incidents viaBaseParser._record_degraded; container formats (zip, archives) already flag members that could not be parsed. -
Opt-in conversion cache (
--cache).grep,search,chunk,view,report,roundtripandoptimizeall take a--cacheflag (and--cache-dir DIR) that stashes parsed documents on disk so repeated runs over unchanged files skip the expensive parse step. The cache is keyed by a fingerprint over the source file (path + size + mtime), the resolved format and parser options, and the all2md version + AST schema — so a changed file, changed options, or a version bump all miss cleanly rather than serving a stale AST. Off by default; also enable globally withALL2MD_CACHE=1, and point it anywhere withALL2MD_CACHE_DIR(defaults to the per-OS user cache directory viaplatformdirs). Exposed programmatically asall2md.conversion_cache.use_conversion_cache(...), which transparently caches everyto_ast()call made inside the context. -
DOCX run-level character styles round-trip. Named character styles on runs ("Intense Emphasis", "Quote Char", a custom style, …) are now captured on the AST inline node's
metadata['source_style']and re-applied when rendering back to DOCX with a template — the run-level analog of the existing paragraphsource_stylehandling. This preserves run styling across a DOCX → AST → DOCX round-trip (and combines with direct bold/italic). Character styles have no Markdown representation, so the name rides only the AST and is dropped on Markdown serialization; without a template that defines the style, application falls through silently, so default output is unchanged.
-
Markdown: footnotes round-trip as Markdown under the default flavor. A footnote reference and its definition rendered to raw HTML on the default flavor, which the default
html_passthroughpolicy then escaped on the next pass — so a footnote did not survive amarkdown → markdownround trip. Footnotes now render in Markdown syntax by default and round-trip intact. -
Markdown: inline marks, superscript and subscript are flavor-aware and round-trip by default. Highlight (
==text==), superscript (^text^) and subscript (~text~) defaulted to HTML tags that the default passthrough policy escaped on reparse. They now default to the roundtrip-safe Markdown spelling (flavors that support the syntax natively emit it directly); set the corresponding*_modeoption tohtmlfor wider display support. -
Markdown: underline (
^^text^^) and non-GFM strikethrough round-trip instead of self-escaping. Underline rendered<u>…</u>by default and the<del>fallback for flavors without~~did the same, both of which the default passthrough policy escaped to<u>…on the next pass. Underline now defaults to the pymdownx^^text^^insert spelling (the old"markdown"mode emitted__…__, which every flavor parses as bold, silently losing the underline); strikethrough on a flavor without~~now emits~~by default. Explicithtmlstill opts into the tags. -
Markdown: inline
$$…$$display math is kept, not dropped. An inline$$…$$span was silently discarded instead of being preserved as display math. -
Markdown: a list survives an admonition that degrades to a labelled quote. When an admonition inside a list item degraded to a labelled block quote, the surrounding list was broken apart; it now stays intact.
-
PDF: prose from every rejected table is preserved, not just degenerate grids. Text inside a detected table's bbox is stripped from the ordinary text stream before the table is validated, so a rejection path that returned
Nonedeleted that text. A prior fix covered only degenerate (1×N / N×1) grids; the oversized-grid, mostly-empty, uniform-cell and dot-leader-TOC rejections still dropped a sparse-but-real table (a financial statement, a form) or a table of contents. All four now demote the region to a paragraph. -
Round-trip scoring counts code, math and raw-HTML block content.
CodeBlock,MathBlock,HTMLBlockand their inline siblings keep their payload in a plain string with noTextchildren, so the text dimension never compared it — a round trip that dropped or mangled an entire code block scored a false100. Their content (and image alt text) is now part of the comparison. -
Confidence: conversions with no quality instrumentation report
not_assessed, not a falsehigh. Formats that emit no scored signals and no degraded events (docx, pptx, html) scored a vacuous100/HIGH, so a mangled.docxread as verified clean. Such a report is now bandednot_assessed; the numeric score is unchanged. -
DOCX: title-promotion inversion clamps heading levels at 6. Demoting the headings after a leading title used an unbounded
level += 1, pushing an H6 to an out-of-spec level 7 that serialization and the round-trip scorer saw. It is now clamped, mirroring the forward transform's bottom clamp. -
all2md optimizesearches only validfigures_parsing/details_parsingvalues. The HTML search space listed values (figure,image,details,content) that no parser accepts; they were silently no-ops and could be written into a recommended.all2md.toml. Replaced with valid choices, guarded by a test. -
Markdown: multi-paragraph and multi-line list items round-trip without collapsing. Three problems in the same surface conspired to flatten lists on a Markdown round trip:
Loose lists were read as tight. mistune 3.x carries the loose/tight flag on the list token itself, not in
attrs; reading it only fromattrsmarked every list tight, so the renderer dropped the blank lines that separate a loose item's paragraphs and they merged into one on reparse.Continuation lines were emitted at column zero. A list item whose content wrapped across several lines (soft-wrapped source, or a multi-paragraph item) indented only its first line; every continuation went to the margin, where it reparses as a lazy continuation that collapses the wrapped lines together — or, when a nested block landed there, breaks the item apart. Code blocks and block quotes inside a list item had the same flaw and could escape to column zero as siblings of the list. Continuation lines, and nested code/quote blocks, now carry the item's indentation.
A nested list as an item's first child double-indented. The first-child render path cleared the indent stacks but left the in-list flag set, so a nested list added its own indent level on top of the marker — rendering
1. - xas1. - x. It now renders flush and is shifted to the marker's content column like any other continuation.A task checkbox (
[ ]) is treated as first-line content rather than marker width, so continuations align to the list marker and don't over-indent into an accidental code block. Net effect: a document like a nested ordered/task list with wrapped prose now survivesmarkdown → markdownunchanged (idempotent), where before it flattened onto single lines. -
DOCX: inline code and block quotes survive the Markdown round trip. Two independent renderer/parser asymmetries on the
md → docx → mdpath, both filed as #71:Inline code was dropped. The renderer emitted a
`code`run with a monospace font but no named character style, so the parser — which recovers inline styling from run styles, not fonts — had nothing to key on, and`inline code`came back as plaininline code. The renderer now tags inline-code runs with aVerbatim Charcharacter style (matching pandoc's name; created on demand and only whenuse_stylesis on), and the parser maps that style back to aCodenode. Recognized style names are configurable via the newcode_char_style_namesDOCX parser option.A block quote came back as a bullet list. The renderer wrote the quoted paragraph as a
Normalparagraph with a left indent, and the parser read that indent as list nesting — so> a quoted linesilently became* a quoted line, which looks intentional and is arguably worse than dropping it. The renderer now applies Word's built-inQuoteparagraph style (which also makes the generated document look right in Word) and, for a single level, no longer sets a bare indent that the parser would misread; the parser mapsQuote/Intense Quoteback to aBlockQuote, coalescing adjacent quote paragraphs into one quote. Recognized style names are configurable via the newquote_style_namesDOCX parser option.Together these take
all2md roundtrip … --via docxon a document with inline code and a quote fromstructure: 33 / inline: 0to100 / 100. Both recoveries require styles (use_styles=True, the default); with styles disabled the render still falls back to font-and-indent as before. -
DOCX: a document title survives the Markdown round trip. Rendering to DOCX applies
TitlePromotionTransform— a leading# H1becomes Word's Title style and every following heading is promoted one level (H2 → "Heading 1") so the document reads correctly in Word. The parser had no inverse: it mapped theTitlestyle to a plain paragraph and left the promoted headings where they were, so# Title/## Sectioncame back as body text plus# Section— the title silently demoted to prose and every heading shifted up a level (all2md roundtrip … --via docxscoredstructure: 67). The parser now maps Word'sTitleback toHeading(level=1, is_title=True)and, when that title leads the document, demotes the following headings one level to undo the promotion — making the transform exactly invertible (structure: 100) while keeping the nice-looking Word output. Word'sTitleis semantically the document title, so this also gives natively-authored Word documents a sensible outline (Title →#, its Heading 1 →##). -
HTML: loose list items no longer grow a paragraph-inside-a-paragraph. A loose item — one whose
<li>already holds a block,<li><p>x</p></li>— was parsed toListItem > Paragraph > Paragraph > Text, because_process_list_item_to_astwrapped every item's content in a freshly synthesizedParagraphwhether or not that content was already a block. No format represents a paragraph nested directly in a paragraph, so the inner node was pure artifact: a consumer walking the AST saw a differentListItemshape depending on how the source HTML happened to be written, and — because our own HTML renderer emits<li><p>…</p></li>— anhtml → htmlround trip accreted one extraParagraphper item on every pass. The parser now adopts a<li>'s block children directly and only synthesizes a wrappingParagraphfor loose inline runs, so<li><p>x</p></li>and<li>x</li>produce the identical AST and the round trip is stable. -
String page ranges select the pages you asked for.
validate_page_range()converted 1-based page numbers to 0-based twice on the string path:parse_page_ranges()already returns 0-based indices, and the result was then decremented again. So every string range was wrong — one including page 1 (pages="1-3") raisedInvalid page number: 0, and every other one silently returned the wrong pages, shifted by one:PdfOptions(pages="2")gave you page 1. The list form (pages=[2]) was correct, and the tests only ever exercised lists, so nothing caught it. The string path now returns the already-0-based parse directly.Two adjacent failures went with it. The CLI could not express the ranges the option documents:
--pdf-pages 1-3was rejected with "Expected comma-separated integers", because the builder resolvedpages: list[int] | str | Noneto justlist[int]— it takes the first non-Nonemember of a union — and ignored the field's own"type": strmetadata, which onlyintandfloatwere honored. An explicit metadata type now overrides inference, so the page spec reaches the converter verbatim. (--pdf-pagesis the only option in the library whose declared metadata type differs from its annotation, so nothing else changes.--save-confignow records the spec as written —"pdf.pages": "1-3"— and configs carrying the older list form still load.) And a range that selected nothing converted the whole document:pages="99"on a 10-page PDF parsed to an empty selection, whichpdf.pyread as "no selection, use every page". It now raises. -
HTML: tables no longer vanish inside
<figure>or inline layout wrappers. On a real arXiv paper (LaTeXML output), 13 tables / 150 rows / 794 cells parsed to 3 empty tables and not a single row. The captions survived, so the output still looked plausible. Two independent defects, either of which was enough to lose every table in the document:<figure>was special-cased to images —_process_figure_to_astlooked for a<figcaption>and an<img>and built its result from those two alone, so any other child (a<table>, a<pre>, a<video>) was never visited. With a caption present it returned aBlockQuoteholding only the caption; with neither image nor caption it returnedNoneand dropped the figure whole. A figure is a container that carries a caption — HTML5 recommends it for captioning tables and code listings too — so its content now goes through the normal block dispatch, whatever that content happens to be.Separately, a block element inside an inline element was discarded outright. An inline context has nowhere to put a block, so
_process_children_to_inlineskipped it behind alogger.debugwhose message said this "should not happen with proper block/inline separation". It happens constantly: LaTeXML scales an oversized table by wrapping it in<span class="ltx_transformed_inner" style="transform:scale(0.7)">, and a<table>inside a<span>was lost — figure or no figure. An inline element wrapping block content is a layout wrapper, not inline content, and is now processed as a block container. Where a block genuinely cannot be kept (<a><div>…</div></a>, since a link cannot hold a block), the drop is now recorded as a degraded-content incident instead of being silent, soall2md reportcan see it.The same paper now parses to 13 tables, 150 rows, 794 cells — every row and cell in the source. Four of the six documented
figures_parsingmodes were also unimplemented and fell through to the blockquote branch:skipdid not skip,paragraphreturned a blockquote, andcaption_onlykept the image. All six now behave as documented, andimage_with_captionno longer drops a caption it cannot fold into the image's alt text. -
all2md optimizeno longer recommends breaking words in half. The objective's text signal was a count of whitespace-separated tokens, which rewards a parse that fragments words — chop one word into two and the document appears to contain more text. Whenmerge_hyphenated_wordsbegan working on native-text PDFs (it was previously a silent no-op), the optimizer immediately found this: repairinghyphen-+ationintohyphenationjoins two tokens into one and so read as losing a word, and it recommended disabling the repair on 17 of 17 real papers. Judged against the publisher's HTML rendering, that advice made every one of them measurably worse (mean −0.03, worst −0.066). Word counts now go throughcontent_tokens(), which rejoins hyphen-broken tokens and drops hyphens, so a fragmented parse and a clean one produce an identical count and the metric cannot have a preference. This is the third time this exploit surfaced (previously viaconsolidate_inline_formatting, where "hello" was counted as "hel" + "lo"); fixing the metric rather than the setting closes the whole class.Relatedly,
KNOBSis now guarded byFORBIDDEN_KNOBS, because two categories of setting look tunable and are not. Correctness settings (merge_hyphenated_words,consolidate_inline_formatting) have one right value — there is no document for which the broken word is the better answer, so there is nothing to search. Content-inclusion preferences (include_comments) change what the user asked for rather than how well it was extracted; the objective rewards recovering more content and comments are words, so it would have recommendedinclude_comments=Trueon essentially every DOCX — advice that leaks reviewer comments the author never meant to publish. -
auto_trim_headers_footersnow removes running headers and footers. It largely did not. Three defects compounded, and each was hidden by the optionalpdf_layoutextra, which labels headers and footers directly — so on a development machine with the extra installed the feature looked fine, while a stock install got almost nothing. (1) Candidates were keyed on their exact text, soPage 1 of 12andPage 2 of 12looked like two unrelated blocks, neither ever repeated, and a footer carrying a page number — very nearly every running footer there is — could never be detected at all. Digit runs are now collapsed when keying, so a running footer is recognized as one. (2) Detection refused to run on documents with fewer than three pages, making the option a silent no-op on every two-page document; two pages are enough to show repetition. (3) The zone filter dropped any block that began inside the header zone, rather than one that lies entirely within it — so a body paragraph starting a few points below the running head was deleted in full, taking the rest of the page with it. On a real FCC filing whose body opened 4pt under the header, the opening paragraph of every page was destroyed. Furniture is always fully contained in the zone (the zone is derived from furniture's own far edge); body text merely pokes into it.Because collapsing digits makes
Section 1andSection 2key alike, a candidate must now also hold still: real furniture is anchored to the page, whereas a heading that merely recurs is anchored to the text flow and lands somewhere different on each page. Verified against arXiv's HTML rendering of 29 papers as an external ground truth — recall did not fall on a single one — and the feature now has tests, which it did not before. -
PDF table detection no longer invents tables out of prose — or deletes the prose when it declines to.
find_tables()fires on plenty of things that are not tables, and a grid with only one dimension is never one: a single column is prose wrapped in pipes, and a single row is a line of text chopped at its word boundaries (on one arXiv paper the sentence "What is the capital of this country?" was rendered as an eight-column table). Those detections are now rejected — but rejecting them was not simply a matter of dropping them. Text inside a detected table's bbox is removed from the ordinary text stream before the table is validated, so a rejection path that returned nothing did not demote the region to prose, it deleted it: doing that silently cost 256 words of real body text across the corpus. Rejected regions now come back as paragraphs. The same applied to regions the layout model predicted as tables and which turned out not to be — a common misfire on academic PDFs, where suppressing the fake tables also removed 530 words of body text with them. Measured across the PDF corpus: junk tables21 → 2, real tables37 → 37(none lost), and body text strictly improves — the junk grids had been shredding words into per-cell fragments (Gender→G+ender), so ~500 real words come back. -
merge_hyphenated_wordsnow actually works on text PDFs. The option is on by default, but for any PDF that did not go through OCR it silently did nothing: the parser delegated the merge to PyMuPDF'sTEXT_DEHYPHENATEextraction flag, and that flag is inert — on PyMuPDF 1.28 / MuPDF 1.29 it does not changeget_text()output in any extraction mode. A word split at a line break came back as"hyphen- ation"instead of"hyphenation"in every ordinary text PDF. The merge is now performed directly on the extracted text blocks (dehyphenate_blocks()), moving the continuation word up into the preceding line so the joined word survives the line-to-paragraph join that callers perform. The existing capitalization rule is unchanged and now applies to native text too: an uppercase continuation keeps the hyphen ("Anglo-\nSaxon"→"Anglo-Saxon"), a lowercase one drops it ("be-\nwusst"→"bewusst"), and hyphens not between two letters ("10-\n20") are left alone. This is the other half of the fix for #51, which addressed only the OCR path — on the explicit assumption that the flag already covered native extraction. -
The options reference regenerates reproducibly. Two
MarkdownRendererOptionsfields default to anUNSETsentinel, and the generator rendered it withrepr()— emitting<object object at 0x...>, a memory address that changed on every run.docs/source/options.rsttherefore showed a spurious diff after any documentation build, burying real option changes in noise. Those fields now render asunset, matching the wordingall2md --helpalready used. Runningscripts/generate_options_doc.pystandalone also works now: its--outputand--narrativedefaults resolved againstscripts/rather than thedocs/source/tree they named, so a hand-run always failed on a missing narrative file. -
DOCX no longer opens with a stray blank line. A Word document whose first paragraph is empty (a common template artifact) produced a leading blank line in the Markdown output — including when that empty paragraph carried a list style, which slipped past the empty-paragraph filter as a blank bullet. Empty paragraphs are now dropped uniformly across regular, list-item, and post-list paths, and the Markdown renderer strips any leading blank line as a final safeguard (so no converter can emit one).
-
Capitalization-aware dehyphenation. When merging words split across a line break by a hyphen (OCR text,
merge_hyphenated_words), an uppercase continuation letter now keeps the hyphen — "Anglo-\nSaxon" becomes "Anglo-Saxon" rather than "AngloSaxon" — so legitimately hyphenated compounds and names survive instead of being fused. -
Persistent search index no longer serves stale results. A keyword index saved with
--search-index-dir(MCPsearch_documents) was reused whenever the directory existed, with no record of the corpus it was built from — so pointing it at a changed corpus, or a differentpathsset, could silently return stale hits. The index now records a fingerprint of the documents and index-relevant options at save time and is rebuilt when they no longer match. -
HTML: an ordered list keeps its
startattribute instead of renumbering from 1. The HTML parser built theListnode without ever reading<ol start="N">, so<ol start="3">came back numbered from 1 on every HTML conversion — the Markdown renderer already honoredList.start, the parser just never populated it. Ordered lists now carry their start value through (a non-numericstartfalls back to 1), so a list that does not begin at 1 survives conversion and themarkdown → html → markdownround trip. -
Markdown: multi-paragraph definition lists round-trip without collapsing. Two defects in the definition-list renderer flattened a list on a Markdown round trip: a description's paragraphs were joined with a single newline (so a second paragraph merged into the first as a lazy continuation on reparse), and consecutive term/description groups were separated by a single newline (so the next term merged into the previous description). Description blocks and term groups are now separated by a blank line and continuation lines indented four spaces, so multi-paragraph descriptions and multiple terms survive intact.
-
Markdown: footnote definitions survive parsing under mistune 3.x. The parser recognized only the legacy
footnote_deftoken and read identifiers fromattrs['label']; mistune 3.x instead groups definitions in afootnotescontainer offootnote_itemtokens (the label living inattrs['key']/ the reference'srawfield, withattrsholding only a numeric index), so every footnote definition was dropped from the AST and every reference lost its identifier. The parser now handles thefootnotescontainer and readskey/rawwith alabelfallback; a multi-paragraph footnote is additionally rendered with a blank line between its blocks and four-space continuation indent, so it no longer collapses into one paragraph on reparse. -
Markdown: a table nested in a list item stays in the list. A table inside a list item was emitted at column zero instead of under the item's content margin, so on a Markdown round trip it re-parsed as a top-level sibling and broke the list apart. Idempotency did not catch it — the broken output was stable — but the round-trip benchmark's HTML-equivalence oracle did. Table rendering now shifts every line to the current indent, mirroring code-block handling, so the table stays inside its item; at the top level the indent is empty and output is unchanged.
-
A long single line is no longer mistaken for a file path and leaked as
OSError. During source resolutionLocalPathRetriever.can_handlecalledPath(value).exists()on raw input before the parse-error wrapper, so a one-line string whose path component exceeds the OS name limit raisedOSError(ENAMETOOLONG)and escapedconvert()instead of surfacing as anAll2MdError. The stat calls are now guarded (mirroring the existing guard in the parser base), so oversized inline content is handled as content.
- Faster CLI cold start. Building the dynamic CLI parser imported every format's
options module and introspected each field, adding ~1.7s to startup even when the
invocation never needed it.
--version/-Vand--about/-Aare now short-circuited before the parser is built (dropping--versionfrom ~2.0s to ~0.8s, the bare import cost),AttachmentOptionsMixinis imported fromall2md.options.commonrather than the eagerall2md.optionspackage, andget_type_hints()is memoized per options class — cuttingcreate_parser()from ~1.7s to ~0.24s after a warm import, which also roughly halves the small-file conversion path. A cold-start benchmark (benchmarks/startup.py) guards these wins against regression. - Cheaper repeated and batch conversions. Four conversion-hot-path wins help
many-small-file and repeated-conversion workloads: the flattened, priority-sorted
converter list is memoized — and invalidated on register/unregister — instead of being
rebuilt on every
detect_format;check_version_requirementis cached per(package, spec)so the dependency-guard decorator stops re-reading installed versions and re-parsing specifiers on every parse/render; resolved option type hints are shared across option construction; and DOCX conversion withattachment_mode="skip"short-circuits before reading the image blob (output is byte-identical).
1.8.2 - 2026-07-09
- Bullet lists no longer disappear when rendering DOCX with a custom template.
When a template lacked the
List Bullet/List Numberstyles, the generated numbering part interleavedw:abstractNumandw:numelements.CT_Numberingrequires everyw:abstractNumto precede everyw:num; Word does not reject the malformed part but silently mis-associates the stray definition, so bulleted lists rendered as plain paragraphs. Numbering definitions are now spliced in ahead of any existingw:num, which also fixes templates that already ship a numbering part (--docx-renderer-template-path,DocxRendererOptions.template_path). - Generated bullets use the correct glyph. The bullet level specified
U+00B7(MIDDLE DOT) while pinning the run font to Symbol, yielding the wrong character. It now usesU+F0B7, the Symbol font's bullet, matching Word's own output. - Generated list styles keep their names.
List Bullet/List Numberwere created as custom styles, colliding with Word's latent built-ins and getting renamed toList Bullet1/List Number1— so any styling a template applied toList Bulletnever took effect. They are now created as built-in styles.
- CI now runs on pushes to and pull requests against
release/**branches, so patch releases cut from a release tag get a full lint/type/test run before merge.
1.8.1 - 2026-07-06
--remote-input-no-require-head-success. Remote document fetching (all2md https://…) previously always required a successful HEAD request before downloading, with no way to opt out — servers that reject or mishandle HEAD could not be read at all.RemoteInputOptionsgainsrequire_head_success(defaultTrue) with a matching CLI flag andALL2MD_REMOTE_INPUT_REQUIRE_HEAD_SUCCESSenvironment variable.
- Legacy
<center>no longer swallows page content.<center>was not in the HTML parser's block-element set, so pages that wrap their main content in it — notably Hacker News item pages — converted to empty output. It is now treated as a block container and its children (paragraphs, tables, …) are preserved. - Options docs now list only flags that actually exist. The auto-generated
options reference invented
--network-*flags with no per-format prefix and showed positive forms of boolean flags the CLI only exposes negated (e.g.--html-network-no-require-https). The generator now mirrors the CLI builder's real naming rules (per-format--<format>-network-*/--<format>-renderer-network-*prefixes, negated defaults, skipped internal fields), and every emitted flag is cross-checked against the live parser.
- Redirect limits are now actually enforced. The
max_redirectscheck ran in an httpx response event hook, which fires before httpx assignsresponse.history— so the redirect count it inspected was always empty and the limit never triggered. Enforcement now uses httpx's nativemax_redirects, surfacing violations asNetworkSecurityError. - Four
NetworkFetchOptionsfields were accepted but silently ignored when fetching attachments/images:max_redirects,allowed_content_types,max_requests_per_second, andmax_concurrent_requests. They are now wired through a single shared fetch helper used by the HTML parser and the DOCX/EPUB/ODP/ODT/PDF/PPTX renderers (rate limiting is applied per converter instance), with a guard test asserting every field of the dataclass is forwarded so new fields can't silently drop out again.
1.8.0 - 2026-07-01
-
all2md help cheatsheet. A bundled, grouped quick reference of the most common commands (convert, view/serve/edit, extract/navigate, grep/search, chunk, diff/lint, generate, transforms, stdin pipes, utilities), printable offline from the terminal (--richrenders it as Markdown). The cheatsheet ships in the wheel as a single source of truth and is mirrored into the docs (:doc:cheatsheet); the quick-help footer now points at it. -
all2md chunk: provenance-aware document chunking for RAG/LLM pipelines. Splits any supported document into chunks and emits them as JSONL (one object per line) — or--format json/pretty. Unlike flat-text chunkers, every chunk carries AST-derived provenance: its section heading/level, and the source page span where the parser tracks it (PDF and friends). Eleven strategies:semantic(default; section-bounded real-token windows),heading,section,auto(coarse, one chunk per boundary), andtoken,sentence,paragraph,word,line,char,code(fine).--max-tokens/--overlap/--min-tokensbound size;--max-heading-level,--include-preamble/--heading-mergetoggles control structure;--token-counter {auto,tiktoken,whitespace}selects the tokenizer. Real BPE token counting usestiktoken(new optional extra:pip install all2md[chunk]); count-only strategies fall back to a whitespace approximation when it is absent. Element handling:--avoid-table-splitand--avoid-code-splitkeep each table or fenced code block whole (one atomic chunk rather than fragmenting it),--drop-elements image,table,…strips noisy node types before chunking,--elide-data-uris(on by default) replaces long base64data:URIs with a short placeholder so embedded images never inflate token counts or shred into noise, and--attachment-mode {skip,alt_text,save,base64}(plus any[pdf]/[html]/ top-level converter keys in a config file) controls how the underlying conversion handles images — so base64 blobs need never reach a chunk. Exposed from Python as a one-callall2md.chunk(source, …)(mirrorsto_markdown: converts and chunks in a single step, derivingdocument_id/path from the source and forwarding converter kwargs), withall2md.chunking.chunk_ast(doc, …)for an AST you already hold; both returnProvenanceChunkrecords. The fine-grained chunkers are vendored from thelocalvectordbsister project. -
Mermaid diagrams, syntax highlighting, and custom themes for
view/serve. The browser preview (all2md view) and local server (all2md serve) now rendermermaidfences as diagrams (via mermaid.js) and syntax-highlight fenced code and raw source files (via highlight.js). Both are on by default with graceful offline degradation, toggle off with--no-mermaid/--no-syntax-highlight, and pick dark variants under--dark. Mermaid rendering is also exposed on the HTML renderer via the newHtmlRendererOptions.render_mermaid(off by default;view/serveenable it).serve's directory listing is rewritten as an aligned table (Name/Size/Modified/Created) plus a card view with a localStorage-remembered toggle and HTML-escaped names.--themenow also accepts a plain.cssfile (wrapped in a minimal shell) and a theme name registered in a new[themes]config table. New "Document Viewer & Server" guide (:doc:viewer).
merge_hyphenated_wordsnow applies to OCR text. PyMuPDF'sTEXT_DEHYPHENATEflag only affects native text extraction, so words hyphenated across a line break (be-\nwusst) survived unmerged whenever a PDF page went through OCR (--pdf-ocr-enabled), even withmerge_hyphenated_words = true. OCR output is now dehyphenated the same way, joining the split halves (bewusst). Numeric ranges (10-\n20) and hyphens not sitting between two letters are left untouched. (#51)- Config-file discovery is now bounded at the home directory.
find_config_in_parents()walked from the working directory all the way to the filesystem root, so an.all2md.*sitting in a shared parent (a drive root,/) would silently apply to every project underneath it. The upward walk now stops atPath.home()(inclusive). Real behavior is unchanged —~/.all2md.*is still found, and the home fallback still covers a working directory outside the home subtree.
1.7.1 - 2026-06-25
- Lint profiles:
all2md lint --profile NAME. Curated, named rule bundles built entirely from the existing 47 rules —prose(typographic polish for long-form writing, ideal for a converted DOCX),accessibility(alt text, link/table semantics, heading hierarchy at error severity), andtechnical-docs(structure and links enforced, prose typography relaxed).--list-profilesprints them with descriptions. Profiles are a base layer: config files and CLI flags layer on top in precedenceprofile< config file < CLI flags. Exposed from Python viaall2md.linter.get_profile_config/available_profiles. New "Linting & Enforcing a Style Guide" how-to guide in the docs walks the full convert → lint → fix → profile workflow. --extractis now repeatable and understands tables and figures. In addition to sections (by name/pattern or#:index) andline:ranges,--extractnow selects tables (table:2,table:1-3,table:*) and figures/images (figure:1,image:*). Pass--extractmultiple times to pull several pieces at once; results are emitted in the order the flags appear, separated by---. A singleline:range still cannot be mixed with other selectors.--extract … ::Nword limit. Append::Nto a selector to cap its output at roughlyNwords, cut at node boundaries so the result stays valid (e.g.--extract "Introduction::500").--slice X/Ypaging. Return the Xth of Y semantic slices of a document to stdout/file without writing split files. The document is divided into exactlyYbalanced slices at section boundaries, and the chosen slice is emitted with a footer hint pointing at the next slice. Mutually exclusive with--extract/--outline/--split-by/--collate.--head [N],--tail [N], and--lines START:END. Simple windows over the rendered Markdown output (1-based, inclusive), mirroringhead/tailand the existing--extract line:range.--head/--taildefault to 10 lines and honor--line-numbers.
- GFM tables nested in list items and blockquotes are now parsed. Pipe tables
indented inside a list item or
>blockquote were previously left as plain text; they are now recognized and parsed into table nodes.
1.7.0 - 2026-06-24
--pagerno longer refuses to page Rich output on Windows/WSL. Paging is left to the environment viaPAGER/MANPAGERand the platform default. When--pager --richis used on Windows without a configuredPAGER(where the defaultmoremangles ANSI color codes), all2md now prints a one-line hint pointing at an ANSI-capable pager such asless -Rinstead of silently dropping paging.- EML: HTML and RTF bodies keep their formatting. Email bodies converted from
HTML (with
convert_html_to_markdown) or RTF are now re-parsed into rich AST nodes, so headings, bold/italic, links, and lists survive into the output instead of being flattened to escaped plain text. Genuine plain-text bodies are still treated as plain text, and raw HTML is never passed through (the Markdown renderer escapes it by default), preserving the parser's sanitization stance.
[rich]config table for theming--richterminal output. A new[rich]table in the config file customizes the colors Rich uses for Markdown elements (headings, links, block quotes, list bullets, inline code, ...) in--richoutput. Bare element names auto-prefix tomarkdown.*; dotted keys pass through verbatim; invalid or non-string entries are skipped with a warning. Previously only code-block syntax themes were configurable.all2md config generateemits a commented[rich]example.all2md help markdown(andhelp md). Added as aliases for the verbosehelp common-markdown-formattingtopic, matching thehelp <format>pattern used by every other format.view/servehonor converter options from the config file. A single config file now drivesall2md,view, andserveidentically -- e.g.[pdf] detect_columns = trueor a top-levelattachment_modeapplies when viewing or serving, not just when converting. (servestill forces base64 attachments so images render in-browser.)- Shorthand flags for
viewandserve.viewgains-d/--dark,-w/--window,-t/--theme,-x/--extract,-N/--no-wait.servegains-p/--port,-H/--host,-B/--browse,-C/--config, and a new-a/--address HOST:PORTthat sets host and port together (-a 0.0.0.0:9000,-a :9000,-a host:). Host uses-Hbecause-his reserved for--help. - EML: RTF message bodies are converted to Markdown. Emails whose body is an
application/rtf/text/rtfpart (e.g. Outlook messages exported via libpst/readpst) previously yielded empty content; the RTF body is now routed through the existing RTF parser as a fallback after plain-text and HTML, and rendered to Markdown. Controlled by the newinclude_rtf_partsoption (--no-include-rtf-parts). (GitHub #39)
1.6.0 - 2026-06-18
list_workspace_filesMCP tool. A new read-only tool (enabled by default) that lets an agent discover the files it is allowed to read before reading or editing them. Returns each file's absolute path and size, supports a globpatternand a workspace-relativesubdirectoryscope, recurses by default, and flagstruncatedwhen the listing is capped. Toggle with--enable-list-files/--no-list-filesorALL2MD_MCP_ENABLE_LIST_FILES.- Additional read-only folders for the MCP server. A new
--additional-read-dirsflag andALL2MD_MCP_ADDITIONAL_READ_DIRSenvironment variable append folders to the read allowlist only (never the write allowlist), and are surfaced in the MCPB manifest. - Batch, in-place
edit_document.edit_documentnow accepts an orderededitsbatch applied to a single parse; the batch is atomic (any failure writes nothing). When a batch contains a mutating action, the document is written back to disk in its original format (disk_written/output_pathin the response). In-place write-back supports md/html/docx/pptx/rst/epub; other formats and read-only targets fail with a clear message. Responses echo only the edited region, not the whole document.
edit_documentauto-detects the source format instead of assuming Markdown, so a.docx(or html/rst/epub/…) is parsed correctly rather than yielding zero sections and cryptic index errors. Mutating edits now require the target to be within the write allowlist (it was read-only before). DOCX write-back uses the original file as a template to preserve styles where possible.- MCP path handling. Relative paths and bare filenames are resolved against the workspace (the read/write allowlist acts as the working directory) across the read, edit, outline, diff, and save tools. A source that is unmistakably a file path but cannot be found now fails loudly — listing the folders searched — instead of being silently treated as inline document text.
- MCP stdio protocol corruption on PDFs. PyMuPDF prints an advisory to
stdout when processing PDFs, which corrupted the JSON-RPC channel and crashed
the connection for any PDF. The server now redirects fd 1 → stderr around each
tool's conversion work and sets
PYMUPDF_MESSAGE=fd:2as an import-time backstop.
1.5.0 - 2026-06-15
- MCP query tools. The MCP server gained three read-only tools so an agent
can query a document corpus, not just convert single files:
search_documents(grep plus keyword/BM25 search across a corpus, returning ranked snippets),diff_documents(compare two documents of any format with unified or JSON output), andget_document_outline(list a document's heading structure, with indices aligned toedit_document's#Nnotation). All three are enabled by default and read-only; each has its own--no-<tool>flag andALL2MD_MCP_ENABLE_<TOOL>environment switch, and path inputs are enforced against the read allowlist.search_documentsrebuilds a fresh in-memory index per call by default; opt into a persistent keyword index with--search-index-dir/ALL2MD_MCP_SEARCH_INDEX_DIR(validated against the write allowlist). Vector/hybrid search modes are rejected with a clear error. - Interactive
all2md batchwizard. A guided workflow that walks through file selection (with a file-type preview), output layout, attachment handling, per-format options, and advanced parameters, then prints the equivalent command and offers to run it. Uses Rich when available, with a plain-input fallback. - Near-source batch attachments. With
--preserve-structureand--attachment-mode save(and no explicit--attachment-output-dir/--attachment-base-url), saved attachments are now co-located in a shared.attachmentsfolder beside each output file and linked with relative paths. Explicit overrides and the legacy single-folder behavior are preserved. - Batch help and docs. The multi-file flags are now grouped under a "Batch
options" group so
all2md help batchworks,all2md help attachmentsresolves to the global attachment topic, and a newbatchpage documents the batch-conversion CLI. - Material for MkDocs markdown syntax. The markdown parser now understands
several niche flavor constructs common on MkDocs sites: admonitions
(
!!! note "Title") and their collapsible???/???+variants, and the pymdownx inline mark family — highlight (==text==, a newMarkAST node), insert/underline (^^text^^), superscript (^text^) and subscript (~text~). Admonitions round-trip to native!!!/???blocks on themarkdown_plusflavor and degrade to labelled block quotes elsewhere; marks round-trip on flavors that support them and otherwise fall back to HTML. Controlled by the newparse_marks/parse_admonitionsoptions (--no-parse-marks,--no-parse-admonitions). - Dark mode for
all2md edit. The in-browser editor now has a 🌙/☀️ toggle in its header, a--darkflag, and an[edit]configdark = truesetting. The toggle choice is remembered across launches via the browser'slocalStorage. - Standalone-window mode for
all2md viewandall2md edit. A new--windowflag (and matching[view]/[edit]config setting) opens the preview/editor in a native OS window with no address bar or browser chrome. It uses the new optionalpywebviewdependency (pip install all2md[window]); without it,all2mdprints a hint and falls back to a normal browser tab.
- The raw-Markdown pane in
all2md editnow uses a monospace font, matching the expectation for editing source text (the rendered preview pane is unchanged).
- Definition lists are now parsed. The
parse_definition_listsoption and its AST handling existed, but the underlying mistune plugin was never enabled, soTerm/: definitionsyntax was silently dropped. It is now wired up (and the handler updated for the current mistunedef_list_itemtoken). - MCPB bundle now ships
rank-bm25. Thesearch_documentsMCP tool defaults to keyword (BM25) mode, but the Claude Desktop bundle didn't installrank-bm25, so corpus search failed out of the box with an install hint. The bundle now depends onrank-bm25directly (not the fullsearchextra, whosefaiss-cpu/sentence-transformersback the vector/hybrid modes that the MCP server rejects).
1.4.0 - 2026-06-11
- EasyOCR engine for PDF OCR. A new binary-free OCR backend, selectable via
OCROptions(engine="easyocr")or--pdf-ocr-engine easyocr. Unlike the default Tesseract engine it needs no system binary (pip install all2md[ocr-easyocr]); it pulls in PyTorch and downloads recognition models on first use. Added anOCROptions.gpuflag (EasyOCR only). Tesseract remains the default with unchanged behavior.
- Corrected stale OCR CLI flags in the README (
--ocr-*→--pdf-ocr-*). rcatopened a transient console window that closed instantly on Windows instead of rendering in the terminal (regression in 1.3.0). When the Windows context-menu integration added a[project.gui-scripts]table, thercatentry point was inadvertently absorbed into it, so its launcher used the GUI subsystem and detached from the console. Movedrcatback to[project.scripts]; it renders in the terminal again.
1.3.0 - 2026-06-11
all2md llm-minify— a token-lean conversion command for feeding documents to LLMs. The default preset keeps Markdown structure (headings, lists, code, tables) while dropping comments, frontmatter, and raw HTML, replacing embedded base64 image data with an alt-text-only reference (so a single inlined screenshot no longer costs tens of thousands of tokens), and collapsing redundant blank lines and interior whitespace.--aggressive(alias--text) strips all formatting down to bare text, and--strip-links/--strip-images/--strip-formattinglayer additional pruning on top of either preset.- Windows right-click context-menu integration via
all2md context-menu(per-user, no administrator rights). It installs a View entry on files (browser preview), an Edit entry on files (in-browser editor), and a Serve entry on folders (local server).installregisters View by default; add--edit,--serve, or--allfor the others.statusreports which entries are installed anduninstallremoves them all. The file entries honor--extensions/--all-textfor which file types they appear on; the folder Serve entry is unaffected. generate-sitegained MkDocs, Zola, and Eleventy generators, joining the existing static-site backends.
- JSON, YAML, TOML, and INI inputs now convert to a fenced, syntax-highlighted code block by default instead of a table/definition-list document (comments are preserved for the formats that have them). This is easier to read and round-trips cleanly. Pass
--<fmt>-no-literal-block(e.g.--json-no-literal-block) to restore the previous structured-document output. - In
all2md viewandall2md serve, external links now open in a new browser tab (target="_blank" rel="noopener noreferrer") so clicking an off-site link no longer navigates away from the document; internal and relative links are unchanged. Plain--to htmloutput is unaffected. - Restructured the bundled agent skills into a single
all2mdskill following Anthropic's progressive-disclosure pattern: a leanSKILL.mdoverview that routes to per-task guides underreferences/(read,convert,generate,grep,search,diff), replacing the previous six top-levelall2md-*skills.install-skillsinstalls the one skill tree;llm-help <topic>maps to the reference files (topics unchanged, plusoverview). - Faster CLI startup: a generated converter manifest lets the CLI resolve formats without importing every converter module at launch.
autoOCR mode now recovers scanned PDFs that previously came back empty. The per-page heuristic counts meaningful (alphanumeric) characters instead of raw string length, so pages whose extracted "text" is only whitespace or invisible glyphs now trigger OCR; a document-level safety net additionally re-runs OCR when the entire document renders near-empty underauto. When OCR is disabled, a hint now suggests--pdf-ocr-mode force.- Corrected stale/renamed CLI flags throughout the bundled skills and docs (GitHub issue #16). Notably
--html-standalone(HTML is standalone by default; use--html-renderer-no-standalonefor a fragment),--docx-template→--docx-renderer-template-path,--pdf-page-size→--pdf-renderer-page-size,--jinja-template*→--jinja-renderer-template*,--pdf-detect-tables→--pdf-table-detection-mode, search--semantic/--mode bm25→--vector/--keyword, and several others. Added a regression test that fails if any removed flag reappears in bundled skill content.
- Comprehensive documentation audit: split the overview into a user-facing guide and a separate architecture-internals page, reconciled configuration-precedence docs, removed overlapping/duplicated guidance, and fixed a range of accuracy and correctness errors across the guides. The supported-format matrix is now auto-generated from the converter registry during the Sphinx build, so it can no longer drift from the code.
1.2.0 - 2026-05-29
- Config-file support for the
view,serve,diff,edit,arxiv, andgenerate-sitesubcommands. Each command reads its own same-named section —[view],[serve],[diff],[edit],[arxiv],[generate-site]— from.all2md.toml/.yaml/.json(or the equivalent[tool.all2md.<command>]block inpyproject.toml), so flags likeview --no-waitorserve --portcan be set once instead of typed every time. Precedence is built-in default < config section < explicit CLI flag, and every one of these commands now also accepts--config <path>and--no-config, mirroring the main converter. Keys are the option name (hyphens or underscores both work); only the matching section is read, so subcommand config never affects a normal conversion and vice versa. A config value can also satisfy an otherwise-required option (e.g.[arxiv]withoutput = "paper.tar.gz"letsall2md arxiv paper.texrun without-o). all2md config generatenow emits a template section for each of those subcommands alongside the format sections, so generating a config is the quickest way to discover every available subcommand key and its default. See the new "Subcommand Options" section indocs/source/configuration.rst.
- The main converter no longer mishandles non-format config sections (
[view],[serve],[diff], etc.) when the input format can't be determined (stdin or failed detection). On that fallback path, any format-qualified option was previously applied blindly, so a subcommand section's keys (e.g.port,no_wait) could be injected as parser keyword arguments and crash the conversion. The fallback is now restricted to recognized parser/renderer format prefixes; unrecognized sections are dropped.
1.1.3 - 2026-05-21
rcat— a standalone "rich cat" command equivalent toall2md --rich. Renders any supported document with rich terminal formatting (syntax highlighting, colors) and automatically falls back to plain Markdown when output is piped or redirected, sorcat doc.pdfpretty-prints whilercat doc.pdf | grep ...stays parseable.all2md servenow accepts glob patterns (e.g.all2md serve "docs/*.docx"). The pattern's anchor directory is served as a listing filtered to matching files; a**segment enables recursive matching. The background live-rescan continues to honor the filter, and a hand-authoredindex.html/README.mdno longer overrides the filtered listing.--include-hiddenflag for both conversion andall2md serve. Dot-files and dot-folders are now skipped by default when scanning directories or expanding globs; pass--include-hiddento include them. Explicitly named files (even hidden ones) are always converted.-fas a short alias for--force-rich.install-skills,edit,lint, andarxivsubcommands now appear in theall2md --helplisting (previously hidden).
--force-richnow actually emits ANSI styling when stdout is not a TTY, so piping forced rich output to a pager works (e.g.rcat file --force-rich | less -R). Previously the forced-rich path still produced plain text because the Rich console was not placed into terminal mode.
1.1.2 - 2026-05-20
all2md servenow auto-renders anindex.html,index.htm,index.md, orREADME.md(case-insensitive, priority order) from the served directory through the active theme instead of the generated file listing. Applies to every directory the server can reach, including subdirectories in--recursivemode. New--force-auto-indexflag opts back into the generated listing.all2md servedirectory mode now picks up newly added, removed, and modified files automatically via a background polling thread. New--poll-interval SECONDSflag (default2.0, set0to disable) controls the rescan cadence; on detected change the cached index page is invalidated and stale file-cache entries for vanished files are dropped.- Line-number navigation for the CLI.
--line-numbers/-lnannotates Markdown output with line numbers:--outline --line-numberslabels each heading with the line it occupies in the full conversion, a normal conversion numbers every line (cat -nstyle), and--extractkeeps the returned lines' original numbers. Line numbers reference the Markdown rendering and are ignored for other targets. --extract line:X-Yselects content by output line range (line:42,line:42-87,line:42-, orline:1-10,42-87; 1-based, inclusive). The selection is taken on the Markdown rendering and re-parsed so it can still render to any--totarget. Paired with--outline --line-numbers, this lets a reader (or an LLM/agent) map a document then pull back just the range it needs.
all2md servenow handles requests on per-connection threads (ThreadingHTTPServer), so a slow conversion no longer blocks other visitors.
all2md serveCtrl+C shutdown was previously delayed until the next inbound request arrived to unblockselect()on Windows. The server now runsserve_forever()in a background daemon thread and the main thread reacts to SIGINT immediately, callinghttpd.shutdown()for a prompt clean exit.--to/--output-formatwas silently ignored when converting to stdout (e.g.all2md doc.md --to htmlprinted Markdown). The option is now tracked as explicitly provided, so it is honored for stdout and takes precedence over output-path extension inference;ALL2MD_OUTPUT_FORMATalso works as a default.- Short UTF-8 files could be mojibaked when chardet misdetected rare multi-byte characters (en-dash, em-dash, smart quotes) as Windows-1252 (e.g. turning "–" into "â€""). A strict UTF-8 decode is now attempted first; since invalid UTF-8 byte sequences raise rather than mis-decode, a successful decode is definitively correct.
1.1.1 - 2026-05-15
- New PDF parsing options for handling brittle real-world layouts:
min_image_dimension(filter decorative artifacts under a pixel threshold),filter_header_footer_images(drop images sitting inside detected page-header/footer bands),collapse_excess_whitespace(collapse long whitespace runs that PDF spans use as layout padding),dedup_running_headings(merge split numbering-prefix headings like"I."+"Background"into"I. Background"), andannotate_rotated_text(opt-in*[rotated 90° counter-clockwise]*marker; default off). - DOCX round-trip formatting preservation.
to_ast/from_ast/from_markdown/convertaccept a newpreserve_formattingkwarg, andall2md editgains a--preserve-formattingflag (on by default for.docx→.docx; pass--no-preserve-formattingto opt out). Round-tripping a.docxthrough Markdown now keeps page setup, theme, headers/footers, and named paragraph styles instead of collapsing them to defaults. The parser stashesparagraph.style.nameon AST nodes viametadata['source_style'], and the renderer re-applies it when the template defines the style — so custom paragraph styles like "Chapter Title" survive instead of degrading to "Heading 1".to_astauto-stashesDocument.metadata['source_path']for file-path inputs so the original document can be reused as a rendering template. Out of scope: run-level character styles still collapse on round-trip (tracked separately). DocxRendererOptions.clear_template_body(defaultFalse) — gates whether a loadedtemplate_pathkeeps its body content (letterhead use case) or has it stripped before the AST is rendered (round-trip use case). Section properties, headers/footers, and style definitions are always preserved.- Corpus benchmark harness under
benchmarks/corpus/— pulls deterministic samples from arxiv, PubMed Central, govdocs1, Apache POI, and Enron, times conversion, and emits a stratified Markdown report. Companioninspectcommand saves converted Markdown next to the source for manual quality review on the slowest, largest, and random subsets. Seebenchmarks/corpus/README.mdand the new "Corpus Benchmark Harness" section indocs/source/performance.rst. - Manual-dispatch GitHub Actions workflow (
.github/workflows/benchmark.yml) that runs the corpus harness on a cleanubuntu-latestVM, caches the ~1 GB corpus between runs, and uploads results as a 90-day workflow artifact. Use for reproducible perf numbers when the local dev box is too noisy. - Benchmark CLI ergonomics:
purgesubcommand to delete the ~1 GB corpus cache,--purge-afterflag for post-run cleanup (CI / ephemeral disks), and--use-layout-modelto opt back into the optionalpymupdf-layoutONNX classifier — off by default in the benchmark for reproducibility across machines. - Reference benchmark snapshots under
benchmarks/reference/— committed before/after.md+.jsonreports (b0e4224-baseline,3516bc9-optimized) that anchor the performance numbers cited in the docs. - New documentation page
docs/source/optimizations.rstwalking through the v1.1.1 PDF performance work: methodology (corpus benchmark + cProfile + inspect), headline numbers, the 000887.pdf case study (5.6 min → 11.65 s), per-commit attribution, and a "what's still slow" section.
- PDF table detection in the default mode now skips PyMuPDF's
find_tables()on pages with no ruling-line drawings or large closed rectangles. Avoids ~1s/page of wasted work on prose-only pages wherefind_tables()would either return nothing useful or fire on decorative frames that downstream guards already reject. Net impact on the 149-doc corpus benchmark: 21.4 min → 6.7 min total (3.2x faster); PDF p50 8.5s → 728ms (12x); the slowest single file 5.6 min → 11.65 s (28x). The newpage_has_table_signals()helper is conservative on error (returns True / runsfind_tables) so PyMuPDF quirks can't silently lose real tables.table_detection_mode="pymupdf"is unchanged — explicit opt-in to always-run behavior. Seedocs/source/optimizations.rstfor the full writeup. image_placement_markersno longer applies whenattachment_mode="alt_text"(the default). Markers had no URL to target in that mode, so![Image from page N]()placeholders were just noise. The option now only takes effect insaveandbase64modes. As a side effect, image-heavy PDFs in the default mode also skip pixmap decoding entirely (≈160 decodes avoided on a typical 32-page workshop PDF).- DOCX rendering re-applies parser-stashed
source_styleparagraph styles when the template defines them, rather than always falling back to built-in heading mapping. DocxRendererOptionsfield order:networkmoved to the end so the auto-generated options docs read in a more natural order. All fields remain keyword-friendly with defaults.
- PDF heading detection misclassified the body=11pt / header=12pt convention as body text (the 1.2 size-ratio default produced an empty
header_id), silently ignored bold-only header styles, and classified mixed-style lines byspans[0]only. Spans are now aggregated per line and style requirements are enforced. - PDF rotated text flooded output with one
*[rotated 90° counter-clockwise]*marker per line (~280 markers on the "Attention Is All You Need" figure-axis labels). Consecutive rotated spans are now grouped within blocks and merged across blocks via metadata, and the annotation is opt-in via the newannotate_rotated_textoption. - PDF table detection fired on TOC dot-leader regions, decorative frames, and oversized empty grids in both PyMuPDF's
find_tables()and the ruling-line fallback. Shared size, sparsity, uniformity, and dot-leader-ratio guards now reject pathological detections in both paths rather than emitting them as garbage tables. - PDF
attachment_mode="alt_text"emitted 100+ empty![Image from page N]()placeholders on image-heavy documents.extract_page_images()now returns early inalt_textmode (suppresses the placeholders and avoids decoding every pixmap only to throw the bytes away). - Tiny decorative PDF images (logo strokes, signature artifacts) and images sitting inside detected page-header/footer regions are no longer emitted as ghost markers — see the new
min_image_dimensionandfilter_header_footer_imagesoptions.
1.1.0 - 2026-05-01
all2md edit FILEcommand — launches a local web-based editor (Toast UI Editor v3.2.2 with Markdown and WYSIWYG modes) pre-loaded with any supported document converted to Markdown. Saves back to disk in any installed target format, with automatic.bakcreation when overwriting. For.mdsources the default save target is the original file (overwrite enabled); for any other format the default target is a sibling.mdfile (overwrite disabled). Toast UI assets are vendored underthemes/assets/and served from/assets/with a strict allow-list.- Linter v2: 27 new rules across three new categories and four expanded ones, bringing the total to 47 built-in rules. New categories: LST (lists), TBL (tables), IMG (images). Expanded categories: STR (
short-section,empty-document,excessive-nesting), HDG (heading-as-sentence,heading-url), LNK (insecure-link,link-text-is-url), TYP (ellipsis-character,space-before-punctuation,consecutive-punctuation). - Auto-fix framework:
all2md lint --fixapplies safe auto-fixes in place. Seven rules ship with safe fixes attached: TYP001 (trailing-spaces), TYP002 (multiple-spaces), TYP003 (straight-quotes), TYP004 (double-hyphens), TYP006 (ellipsis-character), TYP007 (space-before-punctuation), and STR004 (empty-heading). --dry-runflag forlint --fix: report what would be changed without writing the file.- Public API:
all2md.linter.lint_and_fix_document(),lint_and_fix_file(),LintFixResult,LintFix,FixSafety,FixContext,apply_fixes. - Reporters now surface auto-fix results: the text reporter prints per-file
applied N fix(es)plus deferred-conflict counts; the JSON reporter addsapplied_fixes,skipped_fixes,pre_fix_violations, andrewrittenkeys per result entry.
Violation.fixableis now a derived@property(fix is not None) rather than a stored field. Code that constructsViolation(..., fixable=True)will need to pass aLintFixinstead.LintRule.build_violation()accepts an optionalfix=keyword to attach aLintFixto a violation.
1.0.6 - 2026-04-10
- Per-subdirectory index pages with breadcrumb navigation when serving directories recursively (
all2md serve --recursive) - Batch conversion examples added to CLI help output for discoverability
- PDF conversion crash when PyMuPDF detects empty tables (tables with no cells)
view --no-waitdeleting the temp file before the browser could load it
- Default document author is now set to "all2md" when not otherwise specified by the source document
- Auto-release CI workflow: tag pushes now run CI checks then publish to PyPI automatically
- Bumped
codecov/codecov-actionfrom 5 to 6 andactions/setup-pythonfrom 5 to 6
1.0.5 - 2026-04-08
--no-waitflag for theviewcommand for non-interactive use
- Create missing list styles when rendering DOCX with custom templates
1.0.4 - 2026-03-25
- ArXiv submission package generator (
all2md arxiv) — converts any supported document format into a complete ArXiv-ready LaTeX submission archive (.tar.gzor directory) with extracted figures and optional.bibbibliography - Pre-built Agent Skills — 6 focused skill files (
all2md-read,all2md-convert,all2md-generate,all2md-grep,all2md-search,all2md-diff) that teach AI coding assistants (Claude Code, Cursor, Windsurf) how to use all2md. Install withall2md install-skills - Optional
pymupdf-layoutintegration for GNN-based PDF layout analysis — classifies text blocks by semantic role (title, section-header, caption, footnote, etc.) for improved reading order and structure detection. Install withpip install "all2md[pdf_layout]"
- CLI renderer options (e.g.
--docx-renderer-template-path) were silently dropped during format filtering, causing renderer-specific flags to have no effect
1.0.3 - 2026-03-16
- Flow layout engine for Markdown-to-PPTX rendering with template placeholder reuse and inherited built-in styles
- H1-to-Title promotion for Markdown-to-DOCX rendering
- HTML renderer anchor links now use GitHub-style heading IDs (
id="introduction"instead ofid="introduction-1"), so#reflinks resolve correctly - PPTX flow layout no longer overlaps template placeholders; HTML comments route to speaker notes
--collate --outnow writes the target format (e.g. DOCX) instead of raw Markdown- Sphinx documentation build warning from malformed
.. deprecated::directive - Stale mypy
type: ignorecomments across pptx renderer, title promotion transform, and archive parser - Flaky
test_detect_latin1marked asxfail(chardet Latin-1 detection unreliable across platforms)
- Upgraded to Black 26.x and pinned version (
~=26.1) to prevent CI/local formatting drift - Pre-commit format-sync hooks now use
uv runfor Windows compatibility options=accepted as deprecated alias forparser_optionsinto_markdown(); unmatched kwargs now warn
1.0.2 - 2026-02-27
- PPTX flow layout overlapping template placeholders
- HTML comments in PPTX now route to speaker notes
1.0.1 - 2025-12-18
- Softbreak parsing and DOCX CodeBlock styling support
- Dependency-aware file filtering for shell completions
- Diff CLI args renamed to original/modified for clarity
- CLI processor refactoring and PDF parsing internals split out
- Broken test from CLI help text change
- mypy type issue from merging lost branch
- Refactored CLI processors and split PDF parsing internals
1.0.0 - 2025-10-29
- Universal document conversion library supporting bidirectional transformation between various formats and Markdown
- AST-based (Abstract Syntax Tree) pipeline for consistent document manipulation across all formats
- Smart dependency management with format-specific optional dependencies
- Security-conscious design with SSRF protection and archive validation
- Office Documents: PDF, DOCX, PPTX, RTF, ODT, ODP, ODS, XLSX
- Web & Markup: HTML, MHTML, Markdown, reStructuredText, AsciiDoc, Org-Mode, MediaWiki, Textile, BBCode, DokuWiki
- Email: EML, MBOX, MSG (Outlook), PST/OST (Outlook archives)
- E-books: EPUB, FB2, CHM
- Data & Code: CSV/TSV, Jupyter Notebooks (.ipynb), OpenAPI/Swagger, 200+ source code languages
- Archives: ZIP, TAR, 7Z, RAR and other archive formats
- Other: LaTeX, plain text
- Markdown: Multiple flavors (GFM, CommonMark, etc.)
- Office: DOCX, PPTX, PDF, ODT, ODP
- Web: HTML, RTF
- Markup: reStructuredText, AsciiDoc, Org-Mode, MediaWiki, Textile, DokuWiki, LaTeX
- Data: CSV, Jupyter Notebooks (.ipynb), AST JSON
- Templates: Custom Jinja2 templates for any text-based format
- Plain text
- Built-in Model Context Protocol (MCP) server for AI assistant integration
- Smart auto-detection of input sources (file paths, data URIs, base64, plain text)
- Section extraction by heading name for targeted reading
- Security features including file allowlists and network controls
- Support for vision-enabled models with base64 image embedding
- Advanced table detection and extraction
- Multi-column layout analysis
- Intelligent header/footer removal
- OCR support for scanned documents (via Tesseract)
- Page range selection
- Configurable text extraction powered by PyMuPDF
- Built-in transforms:
remove-images: Strip images from documentsremove-nodes: Remove specific node typesheading-offset: Adjust heading levelslink-rewriter: Rewrite URLs with patternstext-replacer: Find and replace text contentadd-heading-ids: Generate heading IDs for anchorsremove-boilerplate: Strip common boilerplate contentadd-timestamp: Add conversion timestamp metadataword-count: Add word count metadataadd-attachment-footnotes: Add footnotes for attachments
- Extensible plugin system for custom transforms via entry points
- Multi-file and directory processing with recursive mode
- Parallel execution for batch conversions
- Directory watching for automatic conversion
- stdin/stdout piping support
- Format-specific options exposed as CLI flags
- Progress bars and rich terminal output
- Preset configurations for common workflows
- Transform application from command line
- Simple
to_markdown()function for quick conversions convert()function for format-to-format conversionto_ast()andfrom_ast()for AST manipulation- Type-safe configuration with dataclass-based options
- Programmatic transform pipeline application
- Direct AST node manipulation for advanced use cases
- Jinja2 template renderer for custom output formats
- Example templates included:
- DocBook XML
- YAML metadata
- ANSI terminal output
- Custom outlines
- Comprehensive test suite with pytest markers (unit, integration, e2e, format-specific)
- Property-based testing with Hypothesis
- Golden/snapshot testing with Syrupy
- Type checking with mypy and custom type stubs
- Code quality enforcement with Ruff
- Pre-commit hooks
- Extensive documentation with Sphinx
- Entry point system for third-party plugins
- Comprehensive README with examples
- API documentation with Sphinx
- Format-specific guides
- Security and threat model documentation
- Plugin development guide
- MCP server configuration guide
- Transform system documentation
- Contributing guidelines
- SSRF protection for remote resource fetching
- ZIP bomb detection and prevention
- Path traversal protection in archives
- Network security controls with allowlists/blocklists
- HTML sanitization with configurable policies
- URL validation and sanitization
- Python 3.10+ required
- Hatchling build backend
- MIT License
- Comprehensive type hints throughout codebase
- NumPy-style docstrings
- Modular architecture with clear separation of concerns