# Defects - jupyterlab_export_markdown_extension

`[ ]` open, `[x]` fixed. Dated notes under each track how it evolved.

## Markdown element rendering

- [x] `DEF-1` **task-list checkboxes not rendered** - `- [x]` / `- [ ]` items exported as literal `[x]` / `[ ]` text in DOCX and PDF (and HTML); cause: the `markdown.Markdown` call uses no task-list extension so the marker passes through verbatim; fix: preprocess task markers to Word's native checkbox glyphs (done U+2612 ☒, open U+2610 ☐), tag the glyph run MS Gothic in DOCX (Word's own checkbox font) so it renders solid, DejaVu covers them in PDF, browsers in HTML; `routes.py`
  - 2026-07-22 reported: "checkboxes are not rendered properly neither in docx nor in pdf"; reproduced - `markdown_to_html('- [x] done')` yields `<li>[x] done</li>`
  - 2026-07-22 attempted: rendered as ballot-check U+2611 ☑ / U+2610 ☐ - user rejected ("use different way"); the ☑ fell back from Calibri and read thin
  - 2026-07-22 fixed: settled on ☒ / ☐ (Word's native checkbox default, also the pandoc/LaTeX convention), glyph run tagged MS Gothic in `style_docx_task_checkboxes`, `preprocess_task_lists` emits the glyphs; mutation-proved across HTML/DOCX/PDF
  - 2026-07-23 hardened: confirming adversarial round found a bare `- [ ]` / `- [x]` with no trailing label passed through literal - the marker regex required whitespace after the bracket; relaxed the tail to `\](?:\s+|$)`; regression test `test_empty_task_item_converts` (known remaining limitation: a task marker inside a 4-space-indented, non-fenced code block is still rewritten)
  - 2026-07-23 hardened: a later round found the fence tracker toggled on the fence char alone, so a shorter ```inside a longer ```` block closed it early and rewrote task markers that were still code; now tracks fence length and closes only on a same-char fence at least as long as the opener (CommonMark); regression test`test_task_marker_in_nested_longer_fence_not_converted`, mutation-proved
  - 2026-07-23 hardened: a fourth round found a same-length ` ```lang ` line inside a block still closed it (a closing fence carries no info string per CommonMark), rewriting still-code markers; the close now also requires a bare fence (nothing after the backticks); regression test `test_task_marker_after_info_string_fence_not_converted`, mutation-proved
- [x] `DEF-2` **blockquotes lose indent, bar and shading in PDF** - `>` quotes rendered correctly in DOCX but in PDF appeared as plain gray italic text with no left indent, no left bar and no background; cause: `convert_docx_to_pdf.process_paragraph` read run formatting only and ignored the `w:pBdr` / `w:shd` / `w:ind` that `style_docx_blockquotes` applied; fix: detect the blockquote paragraph (left `w:pBdr`) and render it as a left-barred, shaded, indented callout in reportlab; `routes.py`
  - 2026-07-22 reported: "'>' quotes are not rendered properly in the pdf, but are rendered properly in the docx"; reproduced - blockquote text sits at x0=42 (same as body), no bar/shading
  - 2026-07-22 fixed: `make_callout` + `blockquote_info` in `convert_docx_to_pdf`; quote text now indents with a gray bar (BBBBBB) and F4F4F4 shading; mutation-proved
- [x] `DEF-3` **alert boxes render as a table header in PDF** - GitHub alerts rendered correctly in DOCX but in PDF showed as a bold blue content-table header cell with grid lines, losing the colored left border and shading; cause: the alert single-cell table reached `convert_docx_to_pdf.process_table`, which styles every table's first row as a header; fix: detect the alert table (1x1, single colored left border, cell shading) and render it as a colored-left-bar shaded callout with normal text; `routes.py`
  - 2026-07-22 reported: "github style alert boxes (especially multiline) are not rendered properly in pdf, are rendered properly in docx"; reproduced - alert content rendered bold #365f91 (header color), purple border 8250DF lost
  - 2026-07-22 fixed: `alert_info` + `process_alert` in `convert_docx_to_pdf`; alert now renders as a purple-bar F4EDFF-shaded callout with normal black text; a real data table keeps its header styling; mutation-proved

## Diagram rendering

- [x] `DEF-4` **mermaid diagrams rasterized with a view window far larger than the shape** - a mermaid SVG scaled to the correct page width but sat inside a canvas much larger than the visible diagram, drowning it in whitespace; cause: `PlaywrightSvgRenderer.render` trusted the SVG's declared `viewBox`, which for many mermaid outputs is much larger than the real content bounding box; fix: measure the rendered content bbox with `getBBox()` in the browser and tighten the viewBox (small padding) before screenshotting; `routes.py`
  - 2026-07-22 reported: "mermaid diagrams ... rendered without proper view window - view window waaaaay outside the visible shape"; reproduced - a 100x40 shape in an 800x800 viewBox rasterized to a 600x600 PNG with content filling 13% width / 5% height (99% empty)
  - 2026-07-22 fixed: getBBox tight-crop in `render`; the same 100x40-in-800x800 shape now fills ~96% width / ~91% height; mutation-proved
  - 2026-07-23 hardened: added regression tests so the edge cases trip early without re-rendering a real diagram - `test_well_framed_diagram_is_not_cropped` (a diagram that already frames its content is left uncropped, since getBBox omits stroke/markers), `test_nonzero_viewbox_origin_is_preserved` (a non-zero viewBox origin is honoured, not zeroed); both mutation-proved
  - 2026-07-23 accepted trade-offs (adversarial rounds, not fixed): the crop uses `getBBox` (geometry only), so on a diagram that trips the <0.8-fill crop a marker or thick stroke exactly at the outer boundary could be clipped - not seen in practice since a diagram's extremity is a node whose geometry is measured, and the 4% pad adds headroom; and an extreme narrow-tall crop inside a much-wider viewBox could drive the raster past Chromium's ~16384px limit - unreachable for real mermaid, which frames tightly (fill ~1.0) so the crop never fires. Both left as-is pending a concrete failing diagram
- [x] `DEF-7` **regression: mermaid diagrams rasterize mostly as whitespace** - reported "inserted as mini svg-s and most of the svg is whitespace as if view window is miscalibrated"; plain `.svg` images were unaffected. Reproduced on `00-inception-poc-owt.md` by rendering its 10 real mermaid blocks with mermaid 11.12.1 and rasterizing through `PlaywrightSvgRenderer`: worst diagram filled **43.3% width / 26.7% height**. Two independent causes in the same path: (a) mermaid stamps an inline `style="max-width: <natural>px"` on the SVG root, which caps the element so the renderer's `width` assignment is ignored and the diagram paints at natural size inside the target canvas - fixed by clearing `maxWidth`/`maxHeight` before sizing; (b) the crop pad was `0.04 * max(w, h)`, one value from the larger axis applied to both, so a wide short flowchart (844x70) got a 34pt vertical pad that nearly doubled its height - fixed by taking the single pad from the SMALLER extent instead. After both, the same 10 diagrams fill **97.1-98.9% width / 81.4-91.4% height**.; `routes.py`
  - 2026-07-23 fixed: deterministic regression test `test_docx_mermaid_image_is_not_mostly_whitespace` pulls the image back out of the .docx, trims the blank margin and asserts the ink spans >=85% of the image width; mutation-proved (39% without the fix)
  - 2026-07-23 hardened: the first pad fix padded each axis by its own extent, which a UX round showed trades one asymmetry for another - a wide flowchart came back with side gutters five times its top ones, and a tall column had its aspect ratio stretched further. One pad from the smaller extent gives an equal printed margin all round (the crop is scaled uniformly) and measures better on the real document: 97.1-98.9% width against 92.8-98.1%
  - 2026-07-23 hardened: an extreme aspect ratio (a long single-column `graph TD`) rasterizes past Chromium's ~16384px viewport and texture caps at the configured export width; the screenshot throws, the renderer falls back to the original SVG data URI, and htmldocx cannot embed SVG - so the diagram vanished from the export with nothing surfaced. The raster is now scaled down to `MAX_RASTER_PX` with the aspect intact; regression test `test_extreme_aspect_diagram_stays_within_the_raster_limit`, mutation-proved (400x8400 unclamped)

## Table pagination

- [x] `DEF-5` **empty-header layout grid detaches its blank header across a page break** - a Markdown image/layout grid written with an empty header row (`|  |  |  |`, the idiom for a borderless grid) had that empty row treated as a repeating styled header: in DOCX marked `w:tblHeader` (Word repeats a blank bar and strands it), in PDF painted a blank blue bar and repeated via `repeatRows`; header ended on one page, image rows on the next; fix: detect an all-empty first row and skip header treatment - no `w:tblHeader` and `tblLook firstRow=0` in DOCX, no header background and `repeatRows=0` in PDF; `routes.py`
  - 2026-07-22 reported: "header table is detached from the rest of the table (images) - with header on one page and row on the next page"; example `01-scoping-poc-mockup-flow.md`; reproduced - the two mockup grids carry an empty header row marked repeating, the Scenario matrix (real header) is unaffected
  - 2026-07-22 fixed: `first_row_empty` guard in `style_docx_table`, `has_header` guard in `process_table`; empty grids no longer mark or paint a header, real headers keep theirs; mutation-proved
- [x] `DEF-6` **PDF drops table-cell images** - images inside a table cell (e.g. the mockup screenshots in `01-scoping-poc-mockup-flow.md`) did not appear in the PDF; cause: `convert_docx_to_pdf.process_table` built cells from `cell.text` only, so inline `w:drawing` images were dropped, the DOCX path renders them fine; fix: `process_table` now walks each cell's paragraphs and renders inline images as reportlab flowables scaled to the column width, via a generalized `process_image(max_width, max_height)`; `routes.py`
  - 2026-07-22 reported: found while reproducing DEF-5; the mockup grids exported in PDF with captions but no images
  - 2026-07-22 fixed: `cell_content` in `process_table` embeds cell images scaled to their column; the 9 mockup screenshots now render (3 phones per row); mutation-proved
  - 2026-07-23 hardened: two confirming adversarial rounds closed a residual `repeatRows` LayoutError. A repeated header re-emits on every continuation page, so each body row must fit in `frame_height - header_h`; an atomic image row that cannot never lays out. First pass widened the guard but keyed off the shortest body row - a second pass caught that a tall image row beside a short one slips through, so the guard now drops the repeat when `header_h + max(body_h) > frame_height` (the load-bearing fix, `splitInRow` carries the row instead), plus a `frame_height - 12` image cap for the header's 4+8pt padding; regression tests `test_pdf_tall_image_in_header_cell_does_not_crash` and `test_pdf_tall_image_body_row_under_repeating_header_does_not_crash`, both mutation-proved
  - 2026-07-23 hardened: a fourth round flagged the `max(body_h)` guard as over-broad - it dropped the repeat for a tall _text_ row too, but a text row splits fine under a repeated header (`splitInRow`), so this regressed the keep-header-on-the-page feature. Guard now keys off each body row's tallest ATOMIC (image) content, not total row height: drops the repeat only when the header fills the page or a body image cannot fit under the repeated header; regression test `test_pdf_tall_text_table_keeps_repeating_header` (header must appear on >1 page), mutation-proved
  - 2026-07-23 hardened: a fifth round found the atomic-only guard still crashed when the HEADER itself nearly fills the page - it left `avail` positive but under one text line, so a one-line body row could not fit under the repeated header (LayoutError). Guard now drops the repeat when `avail` is below one body line (`table_cell_style.leading + padding`), which also subsumes the header-taller-than-frame case; regression test `test_pdf_near_full_page_header_does_not_crash` (image sized just under the 696pt cap to land the header in that window), mutation-proved. Guard is now complete: text rows bounded by the one-line clause, image rows by the atomic-floor clause
  - 2026-07-23 fixed (fidelity): a sixth round found the PDF cell image was scaled to whatever column width its _text_ earned, so an image-only cell (empty header borderless grid - the mockup idiom) collapsed its column and rendered the image ~2pt wide (invisible). The PDF column layout now floors each column to its widest cell image (points from the `wp:extent` EMU), mirroring the DOCX `image_floors`, capped at a fair share so an image cannot starve text neighbours; the same 3-image grid now renders at ~164pt per image. Regression test `test_pdf_image_only_grid_columns_size_to_images` (min image width > 100pt), mutation-proved
- [x] `DEF-8` **empty header row still rendered as a blank row** - a grid written with the empty-header idiom (`|  |  |  |`) renders nothing for that row in markdown, but DOCX and PDF still emitted it as a blank bordered first row above the grid; `DEF-5` had only stripped its header styling and repeat, not the row itself; fix: drop the row outright in `style_docx_table` (DOCX) and `process_table` (PDF), keeping a flag so the body row behind it is not promoted into a header in its place; `routes.py`
  - 2026-07-23 reported: "ms word and pdf render header row - while if empty in markdown it is never rendered"; reproduced - markdown emits an empty `<thead>` (3 blank `<th>`) and the DOCX carried 3 rows, row 0 blank with 0 images above two image rows
  - 2026-07-23 fixed: DOCX grid now exports 2 rows (blank header gone) while a real `| Name | Age |` header is untouched; the PDF needed a matching `dropped_empty_header` guard or `has_header` re-promoted the first content row into a blue header bar (caught by the existing DEF-5 PDF test); test `test_docx_empty_header_row_is_dropped`
  - 2026-07-23 hardened: an architect round found the delete was DATA LOSS on one real idiom - the predicate was text-only, and an image-on-top/caption-below grid puts its pictures in the header row, so a picture-bearing blank-text row was deleted with its images (2 rows / 2 drawings became 1 row / 0). Predicate is now "no text AND no picture" (`w:drawing` or VML `imagedata`) on both paths; regression test `test_image_only_header_row_is_kept_with_its_images`
  - 2026-07-23 hardened: same round found the delete sat behind a `tblPr is None` early-exit; a later round proved that guard unreachable (`CT_Tbl.tblPr` is `OneAndOnlyOne`, and `table.style = ` creates the element regardless), so the guard is gone and the delete simply runs first. HTML had no blank-header rule at all - `drop_empty_table_headers` gives it parity, test `test_html_empty_header_row_is_dropped`
  - 2026-07-23 hardened: the confirming round (architect, bug hunter and UX independently) found the HTML predicate was still text-only - it tests emptiness by stripping tags, which erases an `<img>` with them, so the picture-bearing row DOCX and PDF were hardened to keep was deleted in HTML. Measured: a two-image header went in with 2 `<img>` and came out with 0. The predicate now bails on embedded content, matches `<thead>` with attributes case-insensitively, unescapes entities before the blank test, and requires a surviving body row like its two siblings
  - 2026-07-23 hardened: keeping a picture-only row then exposed the opposite error - it was no longer 'empty', so Word banded it and repeated it as a header while the PDF treated the identical row as body content. Header CHROME is now a separate question from blankness (text-bearing first row, read before the delete) and both formats agree; the blank predicate itself moved to one shared `docx_row_is_blank` used by both paths and widened to `w:pict`, `w:object` and OMML as well as pictures
  - 2026-07-23 hardened: the width estimate widens row 0 by 8% for a header's bold face, but after the delete row 0 is body content - the factor stole width from the column beside it. `fit_docx_table_to_page` now applies it only when `tblLook firstRow` is still set, and the PDF's `string_width` (which selects the bold face the same way) only when `has_header`; regression tests `test_headerless_grid_columns_are_not_skewed_by_bold_widening` and `test_pdf_headerless_grid_columns_are_not_skewed`, mutation-proved at 6.6% skew in DOCX and a 274.6pt-of-500.8pt split in PDF
- [x] `DEF-9` **a table row that fits a page is torn at the page break** - a row holding a caption and its image rendered the caption at the bottom of one page and the image on the next, though the whole row would fit the following page; cause: `splitInRow=1` (added in v1.6.16 to stop `LayoutError` on genuinely oversized rows) is a per-table flag, so it also split any row that merely ran past the space left on the current page; fix: measure row heights after `wrap` and enable `splitInRow` only when some row exceeds the frame, otherwise leave it 0 so a fitting row moves to the next page whole; `routes.py`
  - 2026-07-23 reported: "when full row fits onto one page - it must be rendered as a whole, not broken down"; example `01-scoping-poc-mockup-flow.md`
  - 2026-07-23 fixed: scenario matrix - a row <= page that fits the remaining space renders in place under either setting; a row <= page that does not fit must move whole (`splitInRow=0`) and was being torn (`splitInRow=1`); a row > page must split (`splitInRow=1`) or reportlab raises `LayoutError`. Since the flag is per-table the only correct value is conditional on the measured maximum row height. Test `test_pdf_row_that_fits_a_page_is_not_split` asserts the caption and its image share a page; mutation-proved (caption page 0, image page 1 when forced always-split)
  - 2026-07-23 hardened: an architect round found the same always-split flag on the alert/blockquote callout table in `make_callout`, tearing a callout that would fit the next page whole; it now measures after `wrap` and sets `splitInRow` conditionally, same rule as the content table. The content-table guard is also header-aware - under a repeating header a body row only has `frame_height - header_h`, so the oversize test subtracts it; regression test `test_pdf_callout_that_fits_a_page_is_not_split`
  - 2026-07-23 hardened: a UX round pointed out the fix was PDF-only - `splitInRow` means nothing to Word, which breaks a row wherever the page ends unless the row carries `w:cantSplit`, so the reported symptom survived in the .docx. Every content-table row now carries it; a row taller than a page still breaks, because `w:cantSplit` is a request Word drops when it cannot be met and nothing here sets an exact `w:trHeight` (the setting that would make Word clip instead). Regression test `test_docx_rows_are_marked_unbreakable`
