# Claude Code Journal

This journal tracks substantive work on documents, diagrams, and documentation content.

**Note**: Entries 1-30 have been archived to [JOURNAL_ARCHIVE.md](JOURNAL_ARCHIVE.md).

---

31. **Task - Table-based alert boxes** (v1.3.0): Rewrote alert boxes as single-cell tables for proper margin control, fixed rich content preservation, and cleaned HTML output<br>
    **Result**: Replaced paragraph-border approach with single-cell table wrapping for each alert - this gives full control over cell margins, border placement, and background fill. Key technique: `copy.deepcopy(paragraph._p)` moves the original htmldocx-processed paragraph XML into the table cell, preserving all rich content (hyperlinks, bold, `<br>` line breaks) that htmldocx already converted from HTML. Table uses `w:tblW type="pct" w="5000"` for 100% width, left-only colored border (`w:sz="24"`), cell margins (top/bottom 80tw, left 180tw, right 120tw), and `w:shd` background fill. Consecutive alerts separated by spacer `w:p` elements. Table styling loop updated to skip 1x1 alert tables. Fixed alert label visibility bug where `\u200bNOTE\u200b` marker text remained visible when labels disabled - changed format to `\u200bTYPE\u200b{content}` (no space) and strip type text in `style_docx_alert_boxes()`. Fixed HTML output retaining zero-width space markers by adding `clean_alert_markers_from_html()` method to `ExportHandlerBase` - strips `\u200b` characters and (when labels hidden) the alert type text between markers. Added `TestRichAlerts` class with 5 tests: hyperlinks in alert tables, line breaks, consecutive alert separation, data table exclusion, and label visibility in HTML. Updated README with GitHub Alerts feature, Show Alert Labels setting, syntax highlighting. Created `doc/alert_test.md` comprehensive test document. All 37 tests pass

32. **Task - Dimmer shading and label fix** (v1.3.1): Lightened alert box background colors and fixed showAlertLabels setting not working<br>
    **Result**: Dimmed all alert shading hex values to subtler tints: NOTE `DBEAFE` -> `EDF5FD`, TIP `D1FAE5` -> `EDFBF2`, IMPORTANT `E9D5FF` -> `F4EDFF`, WARNING `FEF3C7` -> `FEF9E7`, CAUTION `FEE2E2` -> `FEF0F0`. Fixed `showAlertLabels` setting having no effect when enabled - `style_docx_alert_boxes()` was unconditionally stripping the alert type text (NOTE, TIP, etc.) from paragraph runs regardless of label visibility. Added `show_labels` parameter to the method; when `True`, only zero-width markers are removed and the type text is preserved. Updated both PDF and DOCX handler call sites to pass `show_alert_labels` through. Updated test assertions for new shading hex values

33. **Task - Remove blank lines before images** (v1.3.5): Eliminated unnecessary empty paragraphs preceding images in DOCX/PDF exports<br>
    **Result**: htmldocx inserts blank `w:p` elements before image paragraphs, creating unwanted vertical spacing. Added `remove_empty_paragraphs_before_images()` method to `ExportHandlerBase` that iterates body paragraphs looking for empty `w:p` elements immediately followed by a `w:p` containing `w:drawing` or VML `imagedata`, and removes the empty predecessor. Applied in both PDF and DOCX handlers after the existing leading-empty-paragraph removal. Also set data tables to 100% page width (`w:tblW type="pct" w="5000"`) in both handlers

34. **Task - LaTeX math rendering**: Added LaTeX math expression rendering to all three export formats<br>
    **Result**: Implemented dual-strategy math rendering. HTML export uses KaTeX via CDN (`cdn.jsdelivr.net/npm/katex@0.16.21`) with auto-render script that processes `$$...$$` (display) and `$...$` (inline) delimiters client-side - added `math_support` parameter to `markdown_to_html()` that injects KaTeX CSS, JS, and auto-render configuration. DOCX and PDF exports use server-side rendering via `matplotlib.mathtext.math_to_image()` (matplotlib 3.10.8 already installed) - added `render_math_to_png()` method that renders LaTeX to PNG base64 data URI with configurable DPI and font size (16pt display, 12pt inline), and `replace_math_with_images()` that protects code blocks (fenced and inline) with placeholders before regex-matching `$$...$$` (display, centered div) and `$...$` (inline, vertical-align:middle). Inline regex `(?<!\$)\$(\S(?:[^$]*?\S)?)\$(?!\$)` requires non-space after opening `$` to avoid false positives on currency (`$100`). Added `mathDPI` setting in `schema/plugin.json` (default 200, range 96-600) and frontend loading in `src/index.ts`. All three handlers updated: PDF and DOCX call `replace_math_with_images()` after alerts and before mermaid, HTML passes `math_support=True` to `markdown_to_html()`. Initially used deprecated `MathTextParser('bitmap')` which was removed in matplotlib 3.10 - switched to `math_to_image()` with `FontProperties` for font size control. Added `TestMathRendering` class with 6 tests: KaTeX scripts in HTML, math delimiters preserved in HTML, DOCX contains math images, PDF succeeds, code blocks preserved, currency not matched. Added math section to `doc/comprehensive_test.md`. All 43 tests pass, lint clean

35. **Task - Math image DPI scaling** (v1.4.2): Fixed math PNG images not matching surrounding font size in DOCX/PDF exports<br>
    **Result**: Two-part fix in `routes.py`. First, `render_math_to_png()` now uses PIL to read actual pixel height of the rendered PNG and calculates effective DPI as `img.height / (fontsize / 72)` - this metadata makes python-docx display inline math at ~12pt and display math at ~16pt, matching body text. Second, `extract_data_uri_images()` line 193 was stripping all img attributes except `src` - fixed to preserve `alt`, `style`, `width`, and `height` attributes via regex extraction from the original tag. All 43 tests pass

36. **Task - Native OMML math equations** (v1.4.0): Replaced PNG math images with native Office Math Markup Language (OMML) equations in DOCX export<br>
    **Result**: The previous matplotlib PNG approach had a fundamental htmldocx limitation - `<img>` tags cannot render inline within text paragraphs, dumping images into separate paragraphs and breaking text flow. Implemented the LaTeX -> MathML -> XSLT -> OMML pipeline using `latex2mathml` (v3.78.1) for MathML conversion and the `mml2omml.xsl` XSLT stylesheet (from meTypeset) for OMML transformation via `lxml.etree.XSLT`. The XSL's root template was commented out by default - uncommented it to enable the `math -> oMath` transform. Added three new methods to `ExportHandlerBase`: `latex_to_omml()` performs the conversion pipeline, `replace_math_with_markers()` inserts zero-width joiner (`\u200d`) text markers instead of images (preserving code blocks and currency from false matches), and `merge_inline_math_omml()` post-processes the DOCX after htmldocx by finding marker text in runs, splitting runs at marker boundaries, and inserting OMML XML elements. Display math (`$$...$$`) gets wrapped in `oMathPara` for centered display. Inline math (`$...$`) gets inserted as `oMath` between text runs in the same paragraph, solving the inline flow problem. PDF handler unchanged (still uses `replace_math_with_images` with matplotlib PNGs). HTML unchanged (KaTeX client-side). Added `latex2mathml>=3.0` to `pyproject.toml`, bundled `mml2omml.xsl` in package directory. Updated `test_docx_has_math_images` to `test_docx_has_omml_math` checking for `oMath` in document XML and absence of marker text, added `test_docx_inline_math_in_paragraph` verifying surrounding text preserved in same paragraph. Removed unused `mathDPI` from DOCX handler. All 48 tests pass

37. **Task - Escaped dollar fix and publish** (v1.4.7): Fixed escaped dollar signs causing false math matches, published to npm and PyPI<br>
    **Result**: Escaped dollar signs (`\$100`) were incorrectly matched as math delimiters, producing false inline math expressions. Fixed by adding `[[ESCAPED_DOLLAR]]` placeholder protection in both `replace_math_with_markers()` (DOCX) and `replace_math_with_images()` (PDF) - escaped dollars are replaced before regex matching and restored after. Updated README with native OMML math documentation, math DPI setting description, and export formats table. Created checkpoint tag v1.4.6 before publish. Published v1.4.7 to npm and PyPI via `make publish`. Updated `schema/plugin.json` mathDPI description to clarify it applies to PDF only (DOCX uses native OMML). Updated `src/index.ts` with math DPI setting for PDF exports. Added math test sections to `doc/comprehensive_test.md`. Removed obsolete `doc/alert_test_output.docx`

38. **Task - Double-backslash dollar fix**: Fixed `\\$` (markdown/LaTeX escaped dollar) rendering as `\$` instead of `$` in DOCX and PDF exports<br>
    **Result**: Documents authored in LaTeX-aware contexts (JupyterLab, Obsidian) use `\\$` for literal dollar signs - `\\` is the markdown escape for backslash, `\$` is the LaTeX escape for dollar. The previous fix only handled `\$` (single backslash + dollar) but not `\\$` (double backslash + dollar). Hex dump confirmed the file `AWS_COSTS_2026-01_PL.md` has literal `5c 5c 24` bytes (39 occurrences). The Python `markdown` library converts `\\` to `\` but doesn't strip the LaTeX `\$` escape, so exports showed `\$524,44` instead of `$524,44`. Fixed both `replace_math_with_markers()` (DOCX) and `replace_math_with_images()` (PDF) in `routes.py` to handle `\\$` before `\$` (order matters to avoid partial matches), and restore both as literal `$`. HTML export unaffected because KaTeX already interprets `\$` as literal dollar client-side. All 48 tests pass

39. **Task - JPEG DPI normalization**: Fixed inconsistent JPEG image sizing in DOCX exports caused by varying embedded DPI metadata<br>
    **Result**: JPEG images from different sources have wildly varying DPI metadata (cameras 96, scanners 220, web tools 72, some encoders 1519) causing python-docx to calculate completely different "native sizes" for same-pixel-dimension images - a 500x281px image at 1519 DPI rendered at 0.3x0.2" (invisible) while a 500x666px at 72 DPI rendered at 6.9x9.2" (overflowing page). Added DPI normalization to 96 in `extract_data_uri_images()` in `routes.py` using PIL `Image.open(filepath).save(filepath, dpi=(96, 96))` after writing temp files - this rewrites DPI metadata without altering pixels. The existing post-processing (lines 1857-1876) then clamps any images exceeding page bounds (7.5"x10"). Copied 5 representative test images (72, 96, 220, 1519 DPI) to `doc/images/`, created `doc/image_scaling_test.md` benchmark document, added `TestImageDPINormalization` class with 3 tests verifying consistent sizing (same-pixel images get same DOCX width), DOCX success, and PDF success. All 51 tests pass

40. **Task - Theme-aware HTML export** (v1.5.0): Added configurable light/dark/system theme for HTML exports with preset background colors and SVG theme forcing<br>
    **Result**: HTML exports previously had no explicit background color and hardcoded `#333` text, making them unreadable in dark mode browsers. Added three new settings in `schema/plugin.json`: `htmlTheme` (enum: light/dark/system, default light) controls theme mode - 'light' forces light theme, 'dark' forces dark theme, 'system' uses CSS `prefers-color-scheme` media query for automatic detection. `htmlDarkBackground` (string, default `#111111` matching JupyterLab dark theme's `--jp-layout-color0`) with recommended presets documented: black `#000000`, JupyterLab dark `#111111`, VS Code dark `#1e1e1e`, slate `#272b31`, GitHub dark `#0d1117`. `htmlLightBackground` (string, default `#ffffff`) with presets: white, GitHub light `#f6f8fa`, warm white `#fafafa`, cream `#f5f5f0`. Updated `markdown_to_html()` in `routes.py` with `theme`, `dark_background`, and `light_background` parameters. Light theme uses GitHub-style colors: `#f6f8fa` code blocks, `#d1d9e0` borders, `#0969da` links, `h1` bottom border. Dark theme: configured dark background, `#e6edf3` text, `#161b22` code blocks, `#30363d` borders, `#58a6ff` links, monokai syntax highlighting. Extended `get_pygments_css(dark=True)` switching from `default` to `monokai` Pygments theme. Added `color-scheme` CSS property on the `<html>` element to force embedded SVGs with `@media (prefers-color-scheme: dark)` to follow the chosen theme - motivated by DeLaval SVGs using theme-aware CSS. Verified with Playwright headless Chromium screenshots across all 6 theme combinations. All 51 tests pass

41. **Task - Banded table rows and derived colors** (v1.5.6): Added banded row striping and hover highlights to HTML tables with auto-derived color palettes<br>
    **Result**: HTML export tables previously had no row differentiation making large tables hard to scan. Added `tbody tr:nth-child(even)` banded rows and `tbody tr:hover` interactive highlight CSS rules to match JupyterLab's markdown preview table behavior. All UI colors (code blocks, borders, table headers, banded stripes, hover highlights) are now derived automatically from the configured background color using an `adjust_color(hex_color, amount)` helper that shifts RGB channels by a fixed step - light theme darkens by 6 (stripe/header) to 46 (border) steps from `htmlLightBackground`, dark theme lightens by 14 (stripe) to 40 (border) steps from `htmlDarkBackground`. This means changing the background color in settings automatically produces a consistent color palette across all elements including the `@media (prefers-color-scheme: dark)` overrides for system theme. For default `#ffffff` light: stripe `#f9f9f9`, hover `#f1f1f1`, border `#d1d1d1`. For default `#111111` dark: stripe `#1f1f1f`, hover `#272727`, border `#393939`. Verified with Playwright screenshots in both light and dark modes. All 51 tests pass

42. **Task - Fix nary OMML empty body**: Fixed summation, integral, and product operators rendering with dashed placeholder square in Word<br>
    **Result**: The `mml2omml.xsl` XSLT transformation produces `<nary>` elements with an empty `<e/>` body because MathML represents `\sum_{n=0}^{N-1} x[n]` as flat siblings (`<msubsup><mo>∑</mo>...</msubsup><mi>x</mi>...`) rather than nesting the summand inside the operator. Word renders the empty `<e/>` as a dashed outline square placeholder with the actual summand appearing separately after the sum symbol. Fixed by adding post-processing in `latex_to_omml()` that finds any `<nary>` with empty `<e/>` and moves all following sibling elements into `<e>`. Tested with DFT formula `X[k] = \sum_{n=0}^{N-1} x[n] \cdot e^{-j2\pi kn/N}`, integrals `\int_0^1 f(x)\,dx`, and products `\prod_{i=1}^{n} a_i` - all produce correct OMML with populated `<e>` elements. All 51 tests pass

43. **Task - HTML alert box styling** (v1.5.9): Added styled GitHub alert boxes to HTML export matching DOCX colored border boxes<br>
    **Result**: HTML export previously stripped zero-width space alert markers via `clean_alert_markers_from_html()`, discarding all alert styling information. DOCX export had full colored alert boxes via `style_docx_alert_boxes()` but HTML got plain unstyled text. Added `style_html_alert_boxes()` method that converts the zero-width marker patterns into `<div>` elements with inline CSS: 4px colored left border, matching background shading, 12px/16px padding, and 4px border-radius. The method handles both `show_labels=True` (where markers are wrapped in `<strong>` tags producing `<p><strong>\u200bTYPE\u200b:</strong> content</p>`) and `show_labels=False` (producing `<p>\u200bTYPE\u200bcontent</p>`) HTML patterns. Uses the same `ALERT_COLORS` dict as DOCX: NOTE blue `#0969DA`/`#EDF5FD`, TIP green `#1A7F37`/`#EDFBF2`, IMPORTANT purple `#8250DF`/`#F4EDFF`, WARNING amber `#9A6700`/`#FEF9E7`, CAUTION red `#CF222E`/`#FEF0F0`. Verified with Playwright screenshot showing all 5 alert types rendered correctly. All 51 tests pass

44. **Task - Fix nested list indentation**: Fixed 2-space indented sub-lists rendering as flat lists in HTML and DOCX exports<br>
    **Result**: Documents using GitHub/CommonMark 2-space indentation for nested lists (`  - sub-item`) were rendered as flat sibling items instead of nested `<ul>` within parent `<li>`. Root cause: Python `markdown` library defaults to `tab_length=4`, requiring 4-space indentation for sub-lists, while most markdown editors (GitHub, Obsidian, JupyterLab) use 2-space convention. Fixed by adding `tab_length=2` parameter to the `markdown.Markdown()` constructor in `markdown_to_html()`. This single-line change correctly handles 2-space and 3-level nested lists. Discovered on `timeline-full.md` which uses `  - źródło:` sub-items under each timeline entry. All 51 tests pass

45. **Task - Fix DOCX anchor links and arrow fonts** (v1.5.12): Fixed two DOCX export bugs - internal anchor links like `[text](#D119)` opened as broken external URLs, and unicode arrows wrapped in `` `←` `` backticks rendered as empty boxes because htmldocx forces Courier font<br>
    **Result**: htmldocx's `handle_link()` in `h2d.py:350` unconditionally sets `is_external=True` for every hyperlink, so hash-prefix anchors (`#D119`) were written as external relationships with `TargetMode="External"` and no matching bookmarks existed in the document body - 229 broken links in `timeline-full v113.docx`. Separately, `font_names = {'code': 'Courier', 'pre': 'Courier'}` at `h2d.py:149` applied Courier to any `<code>`/`<pre>` run content including unicode characters Courier cannot render (U+2190 etc.). Fixed by adding three helper methods to `ExportHandlerBase` in `routes.py`: `inject_anchor_markers()` prepends a `⁣BM:ID⁣` sentinel (U+2063 INVISIBLE SEPARATOR as delimiter) into every HTML element with an `id=` attribute before htmldocx runs, `apply_anchor_bookmarks()` walks the finished DOCX tree replacing each marker with a `w:bookmarkStart` / `w:bookmarkEnd` pair and rewrites every `<w:hyperlink r:id="..">` pointing to a hash-prefix relationship into `<w:hyperlink w:anchor="NAME">` while deleting the now-unused relationships, and `strip_monospace_from_unicode_runs()` drops `w:rFonts` from runs whose text has any non-ASCII character and a monospace ascii font (Courier, Courier New, Consolas, Monaco) so Word falls back to the body theme font (Cambria) which has full arrow coverage. Wired into both `ExportDocxHandler` and `ExportPdfHandler` so the PDF intermediate DOCX benefits too. Added `TestAnchorLinksAndArrowFonts` class with 3 tests verifying internal `w:anchor` hyperlinks, matching bookmarks, external hyperlinks still external, no sentinel leaks, all arrows preserved, and no arrow left inside a Courier run. End-to-end pipeline run on `timeline-full.md` converted 320 anchor hyperlinks and created 238 bookmarks with zero leaked markers. All 54 tests pass

46. **Task - SVG supersampling for cleaner rasterization**: Fixed blurry text and strokes in SVG-to-PNG conversion for DOCX and PDF exports by rendering at 3x target resolution then downsampling with Lanczos<br>
    **Result**: SVG infographics with small font sizes (e.g. `font-size="7.65"` / `8.55` in a 800x345 viewBox) rasterized poorly when cairosvg rendered them at nominal pixel dimensions - the 7-8 px text hit the threshold where grayscale anti-aliasing dominates the glyph shape, producing muddy strokes and uneven letter weights. Validated on `02_challenge_pressures.svg` from the ActOne proposal where comparison against Playwright-rendered PNGs showed title-region mean pixel diff of 13.5/255 at 800px dropping to 4.7/255 at 3000px - meaning the small-size render quality difference is a rasterizer-AA artefact, not a font substitution issue (both cairosvg and Chromium fall back to DejaVu Sans on this machine per `fc-match`). Applied 3x supersampling in `svg_to_png()` and `extract_data_uri_images()` in `routes.py`: render via `cairosvg.svg2png(scale=(dpi/96)*3)` then downscale with `PIL.Image.resize((target_w, target_h), Image.LANCZOS)`. Produces the same output pixel count but with supersample anti-aliasing - visible improvement in title text crispness. Both code paths updated (mermaid fallback uses `svg_to_png`, DOCX/PDF export SVG images use `extract_data_uri_images`). All 54 tests pass

47. **Task - Replace cairosvg with Playwright Chromium for SVG rendering**: Swapped the SVG-to-PNG backend so embedded SVGs in DOCX and PDF get browser-grade CSS, `@media (prefers-color-scheme)`, web fonts, filters, and correct `<tspan>` text-layout with `dx` and mixed `font-size`<br>
    **Result**: cairosvg miscomputed glyph advance for `<text text-anchor="middle">` containing `<tspan>` with a different `font-size` or `dx` offset - reproduced on `16_executive_summary.svg` from the StackRails whitepaper where `−25 to −50<tspan font-size="22">%</tspan>` left the `%` floating far right and `<tspan>weeks</tspan><tspan dx="6" font-size="18">→</tspan><tspan dx="6">days</tspan>` rendered the arrow on top of `days`. Removed `cairosvg>=2.7` from `pyproject.toml`, added `playwright>=1.40` and `Pillow>=9.0`. Added `PlaywrightSvgRenderer` async context manager in `routes.py` modelled on `render_png.py` from `stellars_claude_code_plugins/svg_tools/`: one Chromium instance per `async with`, wraps SVG in a minimal HTML shell sized from the `viewBox`, `new_context(color_scheme=...)` so theme rules resolve, `omit_background=True`, 2x supersample + PIL `LANCZOS` downsample. Refactored `extract_data_uri_images()` to async two-pass (collect SVG matches, render with one renderer, rebuild HTML); deleted the old `svg_to_png()` method since `replace_mermaid_with_images()` now leaves SVGs alone for the single conversion site to handle. New `ChromiumUnavailableError` is caught by both export handlers and returned as `503` with `{"errorCode": "CHROMIUM_UNAVAILABLE", ...}`; `src/request.ts` propagates the `errorCode` and `src/index.ts` shows a `showErrorMessage` popup pointing the user at the install CLI. Plumbed `htmlTheme` through DOCX/PDF requests, mapped to `color_scheme='dark'` only when `htmlTheme=='dark'`. Added `jupyterlab_export_markdown_extension/cli.py` with `install`/`check` subcommands registered as the script `jupyterlab-export-markdown-extension` via `[project.scripts]`; `install` runs `python -m playwright install chromium`, then tries `apt-get install -y --no-install-recommends libnspr4 libnss3 libasound2t64 libatk1.0-0t64 libatk-bridge2.0-0t64 libatspi2.0-0t64 libcups2t64 libdrm2 libgbm1 libxcomposite1 libxdamage1 libxfixes3 libxkbcommon0 libxrandr2 libpango-1.0-0 libcairo2 libfontconfig1 libfreetype6 libdbus-1-3 fonts-liberation` with sudo when not root, falling back to printing the manual command. Added `tests/conftest.py` setting `PLAYWRIGHT_BROWSERS_PATH` to the real `~/.cache/ms-playwright` so pytest-jupyter's temp `HOME` doesn't hide the binary. Verified end-to-end on `stackrails_whitepaper.md`: all 17 embedded SVGs rasterise cleanly including the executive summary. All 54 tests pass

48. **Task - CI fix for pytest 9 and missing Chromium install**: GitHub Actions build job failed at collection because `pytest_plugins` was declared in both the project-root `conftest.py` and the new `tests/conftest.py` - pytest 9.0.3 forbids the non-rootdir form (pytest 8 silently allowed it)<br>
    **Result**: The `tests/conftest.py` added in v1.5.16 to set `PLAYWRIGHT_BROWSERS_PATH` for pytest-jupyter's temp HOME also redeclared `pytest_plugins = ("pytest_jupyter.jupyter_server",)`. Removed the duplicate, kept only the `PLAYWRIGHT_BROWSERS_PATH` setup. Separately, the `build` job ran `pip install .[test]` then `pytest` without downloading Chromium - all SVG-conversion tests would have failed once collection succeeded. Added `python -m playwright install --with-deps chromium` between `pip install` and `pytest` in `.github/workflows/build.yml`; `--with-deps` uses passwordless sudo to apt-install libnss3/libnspr4/libgbm1 etc. Local pytest 9.0.3 passes 54/54. Pushed as `cf17bd9`

49. **Task - SVG export width setting, drop supersampling** (v1.6.1): Replaced the `svgDPI` setting with `svgPixelWidth` (target PNG width in pixels, default 2048 = 2K) and removed 2x supersampling from the Playwright SVG renderer<br>
    **Result**: DPI was an awkward proxy for output size - users think in pixels, not dots-per-inch, when sizing embedded infographics. New `svgPixelWidth` in `schema/plugin.json` (range 400-4096) directly controls the rasterized PNG width; height follows the SVG's viewBox aspect ratio. `PlaywrightSvgRenderer.render()` now takes `width=` instead of `dpi=` and defaults `supersample=1` - Chromium anti-aliases natively, so the render-at-2x-then-Lanczos-downsample pass added latency with no visible gain. Renamed `svgDPI`->`svgPixelWidth` through `src/index.ts`; both DOCX and PDF handlers read `svgPixelWidth` from the request body. Updated `test_svg_pixel_width_parameter`; verified `16_executive_summary.svg` renders at exactly 2048x1099px. All 54 tests pass

50. **Task - Default SVG export width 1920 (Full HD)** (v1.6.2): Changed `svgPixelWidth` default from 2048 to 1920<br>
    **Result**: 2048 ("2K") was an odd anchor; 1920x1080 Full HD is the more familiar reference for "high-res but not huge". Updated the default in `schema/plugin.json` (description now lists 1280 HD / 1920 Full HD / 2560 QHD / 3840 4K), `src/index.ts` (function default and settings var), and `routes.py` (`PlaywrightSvgRenderer.render(width=1920)`, `extract_data_uri_images(svg_pixel_width=1920)`, DOCX and PDF handler request defaults). Whitepaper executive-summary SVG now renders at 1920x1030. All 54 tests pass

51. **Task - Unify diagram/SVG width, mathDPI -> mathPixelWidth** (v1.6.3): Dropped the separate `diagramDPI` setting and routed Mermaid diagrams through the same server-side Playwright rasterizer as plain SVGs; renamed `mathDPI` to `mathPixelWidth` (PDF-only, default 800)<br>
    **Result**: Mermaid was rasterized client-side via canvas (`imgElementToPng`, deleted) at a calibrated DPI - inconsistent with how every other SVG is now handled. The frontend now just hands the diagram SVG to the server (`png: ''` in the `mermaidDiagrams` payload); `extract_data_uri_images` rasterizes it via Chromium at `svgPixelWidth`, so one width setting governs both pure SVG and Mermaid. For math, `render_math_to_png` probes the equation's natural size then renders at a DPI that lands near the requested pixel width (clamped 72-1200 so a short inline `$x^2$` stays ~233px instead of ballooning to a huge file). `replace_math_with_images` and the PDF handler now take `width=`; DOCX (OMML) and HTML (KaTeX) are unaffected - schema description states "PDF only". Updated `schema/plugin.json`, `src/index.ts`, `README.md`. All 54 tests pass

52. **Task - Embed remote images in DOCX/PDF export** (v1.6.4): `embed_images_as_base64` now fetches `http://`/`https://` image URLs and inlines them as base64 data URIs so shields.io badges and other remote images actually render in DOCX/PDF<br>
    **Result**: Previously the helper passed remote URLs through untouched (`routes.py:348` early-return) and htmldocx dropped them silently because it can't fetch URLs - reported on `private/henryk/henryk-simulations/docs/incident_analysis.md` where 20 shields.io severity badges (PLAUSIBLE/EXTREME/AIS 5+/etc.) vanished from the DOCX. Fix adds a `fetch_remote(url)` inner helper using `urllib.request.urlopen` with a 10 s timeout, 5 MB response cap, custom User-Agent, and an in-call URL cache so duplicate badges only fetch once. Response `Content-Type` drives the data URI's MIME (charset suffix stripped). Any failure (404, timeout, oversize, TLS, network down) silently falls back to leaving the original URL in place - no regression. For shields.io specifically the response is `image/svg+xml`, so the data URI flows through the existing `extract_data_uri_images(convert_svg=True)` Playwright rasterizer at `svgPixelWidth`. Verified end-to-end on `incident_analysis.md`: 0 remote refs left after embed, 17 unique PNGs after md5 dedup. All 54 tests pass

53. **Task - Scale table-cell images to fit the cell** (v1.6.5): The DOCX image-resize loop scaled every image to page width (7.5"), so photos inside a multi-column table overflowed their narrow cells and Word clipped them<br>
    **Result**: Reported on `incident_analysis_pl.md` - a 2-column appendix table held four 4032x3024 px photos; the fit-to-page loop in `ExportDocxHandler` shrank them to 7.5" each, but each table cell is only ~3.75" wide, so Word cropped them at the cell boundary. Root cause: the loop constrained every inline shape to `page_width` regardless of whether it sat in a table. Added a `container_width()` helper that walks an inline shape's XML ancestry - if nested in a `<w:tc>`, it finds the enclosing `<w:tbl>`, counts `<w:tblGrid>/<w:gridCol>` columns (falling back to `<w:tc>` count in the first row), and returns `page_width / ncols - 0.2"` as the width constraint; otherwise the full page width. The resize loop now scales each image to its own container - body figures still reach 7.5", the appendix photos land at 3.55" and fit their cells (portraits 3.55x4.73"). Added `TestTableImageScaling` regression test: a 4000x3000 image in a 2-column table must come out under 4" wide. All 55 tests pass

54. **Task - Render blockquotes and fix loose-list bullets** (v1.6.6): Blockquotes exported with no visual distinction and loose-list bullets rendered as empty bullet glyphs followed by un-bulleted text<br>
    **Result**: Two htmldocx structural blind spots confirmed in `h2d.py`. Loose list items (`<li><p>text</p><blockquote>...</blockquote></li>`) - `handle_li` opens a bullet paragraph, then the inner `<p>` opens a fresh `Normal` paragraph, so the bullet glyph ends up empty and the text loses its bullet. Blockquotes - there is no `<blockquote>` handler at all, so inner `<p>` become plain paragraphs with no indent or bar. Added `restructure_html_for_docx()` (bs4, a guaranteed htmldocx dep): unwraps the leading `<p>` of each `<li>` so the text rejoins the bullet, and prefixes each blockquote paragraph with an indent-encoding sentinel `⁣BQ:<indent>⁣` (indent derived from list+quote nesting). Added `style_docx_blockquotes()`: finds marked paragraphs (iterating all `w:p` incl. table cells), applies left indent, a gray `w:pBdr` left bar, `F4F4F4` shading and muted italic runs, then strips the marker - inserting `pBdr`/`shd` via `insert_element_before` to keep CT_PPr order valid. Wired into both DOCX and PDF handlers. Verified by LibreOffice-rendering the exact reported markdown: bullets restored, quotes shown as indented gray quote-blocks; real file gives 31 bulleted items, 0 empty bullets, no marker leak. Added `TestBlockquotesAndLooseListBullets` (3 tests). All 58 tests pass

55. **Task - Strip XML/DOCTYPE prologue from rasterized SVGs** (v1.6.7): Mermaid diagrams exported with a stray `]>` text artefact in the top-left corner of every image<br>
    **Result**: Reported on `poc-models-sizing-and-effort.md`. JupyterLab's mermaid renderer (`cleanMermaid` in `jlab_core`) prepends an XML+DOCTYPE prologue to each diagram SVG - `<?xml ...?>` then `<!DOCTYPE svg PUBLIC "...svg11.dtd" [ <!ENTITY ...> ... ]>` - and that prologue rides through the capture/base64 pipeline into `PlaywrightSvgRenderer.render()`. The SVG string is embedded in an HTML document and parsed by Chromium's HTML parser, which has no concept of a DOCTYPE internal subset: it ends the DOCTYPE at the first `>` (inside the first `<!ENTITY>`), spilling the rest of the subset - ending in `]>` - as a visible text node at the document origin. Reproduced exactly by rendering a mermaid SVG (via the bundled mermaid.min.js) wrapped in the real prologue: black `]>` top-left. Fixed by cutting `svg_text` to its first `<svg` occurrence in `render()`, dropping the prologue; Chromium renders the element fine without it, and the fix is general, not mermaid-specific. Added `TestSVGDoctypePrologue` regression test asserting zero dark pixels in the top-left 80x80 after rasterizing a DOCTYPE-prefixed SVG. All 59 tests pass

56. **Task [Extended] - DOCX render theme setting and coloured-HTML pills** (v1.6.8): Added a Word/PDF rendering-theme setting and fixed coloured inline HTML that exported wrong, both surfaced by `poc-models-sizing-and-effort.md`<br>
    **Result**: Two related pieces. (1) Coloured HTML: htmldocx parses only hex colours so `<span style="color:green">` rendered black, and it maps `background-color` to a Word *highlight* from a tiny fixed palette so pill badges (`background-color:#15803d;color:#fff`) became white-on-lightGray and invisible. Extended `restructure_html_for_docx()` to normalise named CSS colours to hex (new `_CSS_NAMED_COLORS`) and to strip each background and re-encode it as a `⁣PILL:<hex>⁣` marker; new `style_docx_color_runs()` turns the marker into true run shading (`w:shd w:fill=<hex>`), removing any bogus highlight. LibreOffice render confirms High/Med/Low now show white text on green/amber/slate badges and green/red text render in colour. (2) Theme: new `docxTheme` setting (light default - Word is usually printed - / dark / auto) drives the Playwright `color_scheme` for DOCX/PDF (replacing the reuse of `htmlTheme`), so `@media (prefers-color-scheme)` SVGs resolve correctly (verified light->white, dark->black). Crucially mermaid uses hardcoded colours, not media queries, so `color_scheme` cannot re-theme it; instead the frontend now recovers each diagram's source from the `<figure>`'s `<pre><code>` and re-renders it via `IMermaidManager.getMermaid()` in the chosen theme (`captureMermaidDiagrams` is now async, with a safe fallback to the captured SVG on any failure), fixing dark-UI exports that produced light-grey edges invisible on white paper. Added `@jupyterlab/mermaid` as a shared-singleton dependency. Added `TestColouredHtml` and `TestDocxTheme`. TS compiles, eslint clean, labextension builds; all 63 tests pass

57. **Task - Embed and right-size HTML `<img>` table badges** (v1.6.9): Inline HTML `<img src="...svg">` badges/pills inside Markdown tables broke in DOCX/PDF and, once embedded, blew up to cell width<br>
    **Result**: Surfaced again on `poc-models-sizing-and-effort.md`, which writes size/pill badges as raw HTML `<img>` in table cells (relative local SVG paths) since Markdown image syntax can't carry inline sizing. `embed_images_as_base64()` matched only `![alt](path)`, so the 159 `<img>` tags never inlined - htmldocx can't fetch relative paths, so every badge vanished. Refactored to a shared `resolve_src()` (data:/http/local) plus a new raw-`<img>` pass that rewrites just the `src`, preserving other attributes. Second bug: every SVG rasterized at the 1920px diagram width, so a 22px pill became a 20in PNG scaled to fill its narrow cell, pushing the `64h` text to the next line. New `_badge_render_spec()` reads `max-height`/`max-width` from the tag; for sizes <=200px it returns a small render width plus 4x DPI (384) so the PNG embeds at physical size (22px -> 0.229in), not page width. Header banner and the 1000x1320 coverage diagram still render full-width. LibreOffice DOCX->PDF render confirms crisp small pills; coloured spans and the alert box still hold. Added `TestHtmlImgBadges` (3 tests); all 66 tests pass

58. **Task - Harden badge embedding; contain LFI/SSRF** (v1.6.10): Adversarial-review sweep over the v1.6.9 `<img>` badge change, plus a security pass, found real parsing bugs and pre-existing local-file/SSRF exposure in `embed_images_as_base64`<br>
    **Result**: Badge logic in `_badge_render_spec` hardened - strict numeric parse (no crash on `1.2.3px`), an effective-height model so width-only badges and `max-height`/`max-width` clamps size correctly, an attribute tokenizer that reads only real `width`/`height` (ignoring values inside `alt`/`data-*`), and a render-width DoS clamp (`_BADGE_RENDER_MAX_PX`). Embed regexes anchored: a `src=` lookbehind blocks `data-src`/`lowsrc`/`xlink:src`, and a quote-aware `<img>` matcher stops truncation on `>` inside attribute values. Security: local reads now `resolve()` and must be `is_relative_to` the Jupyter root (fail-closed), blocking `../etc/passwd` traversal; remote fetches gated by an `ipaddress.is_global` allowlist, a `getaddrinfo` host pre-check, and a connection-time peer-IP check that defeats DNS rebinding, plus per-hop redirect re-validation and proxy-awareness so badges still load behind a proxy. Added `TestImageEmbedSecurity` and badge-hardening tests; all 71 tests pass

59. **Task - Fix Python 3.9 import crash from PEP 604 unions** (v1.6.11): v1.6.10's hardening added `str | None` / `bytes | None` annotations to `routes.py` without `from __future__ import annotations`, so the server extension failed to import on Python 3.9 and CI `test_isolated` (3.9) went red - 1.6.10 shipped broken to PyPI/npm<br>
    **Result**: `jupyter server extension list` reported `X unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.9.25 - PEP 604 `X | Y` unions are runtime-valid only on 3.10+, and the six new annotations (signatures at `routes.py:203/509/607/628` plus two local-var annotations) evaluated eagerly at def time. `cli.py` already carried the future import; `routes.py` did not. Added `from __future__ import annotations` as the first statement after the docstring, turning every annotation into a deferred PEP 563 string never evaluated - `|` never executes on any Python >=3.7. Verified annotations now stringified, all 71 tests pass; re-released as 1.6.11 because PyPI 1.6.10 is immutable

60. **Task - Drop Python 3.9 support; require >=3.10** (v1.6.12): The 1.6.11 fix kept 3.9 working via `from __future__ import annotations`, but the project chose to drop 3.9 rather than carry it - PEP 604 `X | None` unions in `routes.py` are only runtime-native on 3.10+<br>
    **Result**: Raised the support floor to Python 3.10 - `pyproject.toml` `requires-python = ">=3.10"` with the `Programming Language :: Python :: 3.9` classifier removed; `.github/workflows/build.yml` `test_isolated` job now installs Python 3.10 (was 3.9); `README.md` Requirements state Python >= 3.10. The `from __future__ import annotations` import added in 1.6.11 stays - harmless on 3.10+, consistent with `cli.py`, and a guard against the eager-annotation class of bug. CI on the floor-change commit (`7823db0`) went fully green - `test_isolated` on 3.10 passes alongside build, integration and check-release. Released as 1.6.12 so PyPI metadata enforces the >=3.10 floor for installers

61. **Task [Short] - Rename settings menu entry** (v1.6.13): Renamed the plugin `title` in `schema/plugin.json` from "Export Markdown Extension" to "Markdown Export Extension" - the label JupyterLab's Settings Editor lists for the extension<br>
    **Result**: One-line schema change; `jupyter.lab.setting-icon-label` ("Export Markdown") left untouched. Schema reships via the release build

62. **Task - Mermaid export preview-independence** (v1.6.14): Mermaid diagrams exported as raw ```mermaid code (not images) when the `.md` was open in the source editor with no rendered preview - reported on `poc-aws-architecture.md`, whose DOCX carried the mermaid source as a Courier code block while the three `![](.svg)` images embedded fine<br>
    **Result**: `captureMermaidDiagrams` (`src/index.ts`) scraped the rendered `.jp-RenderedMarkdown` DOM, but the export path resolves from `context.path`, so exporting from the plain editor found no rendered SVG and the block fell through as code; server-side `![](.svg)` embedding was unaffected, hence only mermaid broke. Removed the early `return` on missing rendered markdown and added a preview-independent fallback: when DOM capture yields nothing, read `context.model.toString()`, extract ```mermaid blocks, render each via `IMermaidManager` in the export theme. Also pass the manager for HTML (was null) gated by a new `reThemeDom` flag so HTML's primary path is unchanged. Verified end-to-end in live JupyterLab (editor-only): DOCX went from 3 media + raw code to 4 media with a 1920x491 mermaid PNG, no leaked source. 71 tests pass

63. **Task [Extended] - Wide tables wrap instead of overflowing** (v1.6.15): Tables wider than the page ran past the right border in all three exports instead of wrapping - a 6-column repro lost three whole columns off the sheet in PDF<br>
    **Result**: PDF was the worst: `process_table` passed raw strings to reportlab with no `colWidths`, so cells never wrapped and columns took their natural single-line width (text reached x=617pt on a 612pt page). Cells are now `Paragraph` flowables sized by two shared helpers - `measured_column_widths` builds each column's natural (longest line) and minimum (longest word) width from a caller-supplied metric, `fit_column_widths` scales them to the frame keeping every minimum, so wrapping lands on word boundaries; cell padding steps 6->1->0pt as the fair share narrows. DOCX tables carried `tblW=100%` but autofit layout, which Word widens on an unbreakable token; `fit_docx_table_to_page` writes a proportional `tblGrid` plus `tcW` and sets `autofit=False`, only when the table overflows. HTML needed more than `overflow-wrap: anywhere` on cells: a depth-tracking `wrap_html_tables` boxes each outermost table in a `.table-scroll` div - comment- and attribute-aware, so a `<table>` in an HTML comment cannot open a wrapper that never closes - plus an `@media print` override so paper does not crop the hidden columns. Page geometry is one source of truth (`PDF_PAGE_MARGIN`, `PDF_FRAME_PADDING`, `PDF_TABLE_CELL_PADDING` constants). Six adversarial rounds (architect, bug-hunter) caught fourteen real defects, most of them regressions inside the fixes themselves: `LayoutError` on a page-tall wrapped row, a 500 at 45+ columns, float rounding skipping 4% of over-wide tables, alert-box images trusting a decorative grid, `w:tblLayout` out of schema sequence, a width clamp inert below 529 columns yet the sole overflow cause above, a 1x1 heuristic starving single-cell tables of fitting, a zero-`wp:extent` image in 50-column tables, and the nested- and commented-table wrapper faults. 91 tests pass; every guard was mutation-proved to fail on the unfixed code (PDF text at x=743 against a 576pt margin) rather than pass vacuously

64. **Task - Tables and diagrams fit the page** (v1.6.16): Two acceptance criteria - a table must not strand its header row alone at a page break, and a mermaid/image must fit page height not just width - across PDF, DOCX and HTML<br>
    **Result**: PDF `process_table` gained `repeatRows=1` (reportlab then defers a header-only split and repeats the header); `process_image` clamps to the frame height after width, sharing one hoisted `frame_width`/`frame_height` with the table path and removing a stray `7*inch`. DOCX marks row 0 `w:tblHeader`; HTML print CSS adds `thead`/`tr` break-avoid and `img { max-height: 9in }` (`100vh` and `width:auto` proved unreliable in Chromium print - 2 to 3 pages). Adversarial review (architect + bug-hunter) caught a HIGH: `repeatRows` re-armed the `LayoutError` that `splitInRow` prevents whenever the header row itself exceeds a page (a page-long alert callout, a header-only table) - fixed by measuring row 0 and dropping the repeat when it overflows. New acceptance-criteria doc `docs/acc-crit-jupyterlab-export-markdown-extension.md`. 98 tests pass, eight page-fitting guards mutation-proved.

65. **Task [Extended] - Export-fidelity defects and adversarial hardening** (v1.6.17): Registered six export-rendering defects in a new `docs/defects.md` and fixed all, then hardened the riskiest fixes across a seven-round `/devils-advocate:adversarial-review` gauntlet<br>
    **Result**: DEF-1 task-list `- [x]`/`- [ ]` rendered as literal text - `preprocess_task_lists` now emits Word's native checkbox glyphs (☒ U+2612 / ☐ U+2610), tagged MS Gothic in DOCX (`style_docx_task_checkboxes`) so they render solid, DejaVu covering them in PDF. DEF-2/DEF-3 blockquotes and GitHub alerts rendered in DOCX but not PDF - `convert_docx_to_pdf` gained `make_callout`/`blockquote_info`/`alert_info` so a `w:pBdr` quote and a 1x1 coloured-border alert become left-barred shaded callouts, consecutive quote paragraphs grouped into one box. DEF-4 mermaid SVGs rasterised inside a viewBox far larger than the shape - `PlaywrightSvgRenderer.render` measures `getBBox` and tightens only when >20% empty, keeping a declared non-zero origin otherwise. DEF-5 empty-header layout grids stranded a blank repeating header - detected and de-headered. DEF-6 PDF dropped table-cell images - `cell_content` embeds them scaled to the column. The adversarial gauntlet then exposed progressively deeper crashes in the PDF table repeat-header guard: a repeated header re-emitting on every continuation page starves body rows it can't fit, so `repeatRows` was reworked three times (`min`->`max` body height -> per-row atomic-image floor -> plus a one-line-text clause via `table_cell_style.leading`), every `LayoutError` reproduced then mutation-proved fixed. It also found the image-only borderless grid (the mockup idiom) collapsed its text-sized columns so cell images rendered ~2pt wide (invisible) - fixed by flooring each PDF column to its widest image (points from `wp:extent` EMU, mirroring the DOCX `image_floors`, capped at a fair share), 2pt->164pt. Plus a CommonMark fence tracker (length + bare-closer) and an empty-task-item regex. Ten new mutation-proved regression tests; 123 pass. `process_paragraph`'s polymorphic list return confirmed safe (both callers route through `add_to_story`). Also fixed CI, red since v1.6.16: the PDF-inspection tests `import fitz` but `pymupdf` was absent from the `test` extra, so the isolated `pip install .[test]` job failed with `ModuleNotFoundError: No module named 'fitz'` (2 failed, 96 passed) - added `pymupdf` to `pyproject.toml`.

66. **Task [Extended] - Blank grid headers, unbreakable rows and mermaid framing** (v1.6.18): Three reported export defects - DOCX/PDF rendering a header row markdown never shows, a table row torn across a page break, and mermaid diagrams rasterizing mostly as whitespace - fixed across all three formats and hardened through an architect/bug-hunter/UX adversarial round<br>
    **Result**: DEF-7 had two independent causes in `PlaywrightSvgRenderer.render`: mermaid stamps an inline `max-width: <natural>px` on the SVG root, which caps the element so the renderer's own width is ignored and the diagram paints small inside the target canvas; and the crop pad was taken from the larger extent, swamping a wide short flowchart. Clearing both caps plus one pad from the SMALLER extent took the reference document's 10 diagrams from 43.3% worst-case ink width to 97.1-98.9%. DEF-8 - markdown renders nothing for the empty-header idiom (`|  |  |  |`) that a borderless image grid needs, so the row is now deleted outright on all three paths rather than merely unstyled, behind one shared `docx_row_is_blank` predicate that counts pictures, `w:pict`, `w:object` and OMML as content. DEF-9 - reportlab's `splitInRow` is per table, so the v1.6.16 `LayoutError` fix also tore any row that merely ran past the space left on the page; row heights are now measured after `wrap()` and intra-row splitting enabled only when a row genuinely exceeds the frame, header-aware and applied to `make_callout` too. The adversarial round (architect, bug hunter and UX independently) converged on one blocker: the HTML predicate tested emptiness by stripping tags, which erases an `<img>` with them, so the picture-bearing row DOCX and PDF were hardened to keep was silently deleted in HTML - measured 2 images in, 0 out. Keeping it then exposed the inverse, Word banding and repeating a picture-only row as a header while the PDF read it as body, so header chrome now keys off text in the original first row. UX also caught that DEF-9 was PDF-only (`splitInRow` means nothing to Word - every content-table row now carries `w:cantSplit`) and that an extreme aspect ratio could blow past Chromium's ~16384px raster cap, dropping the diagram from the export with nothing surfaced (`MAX_RASTER_PX` clamp). Both bold-width estimates (DOCX `tblLook`, PDF `string_width`) now skip the 8% header factor when there is no header. 136 tests pass, every new guard mutation-proved; verified end-to-end on the two reference documents - 11 diagrams at >=97.1% ink width, both grids headerless, 10 screenshots in each of DOCX/PDF/HTML, each grid row's captions and images on one page.
