Skip to content

Changelog ​

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 (what each number means for markitai: versioning). Releases 1.x were the Python implementation; their notes are in the 1.x part below.

[2.2.0] - 2026-10-26 ​

Adds markitai update and a notice when a newer release is out. To get 2.2.0, update the way you installed: run the setup command again, or uv tool upgrade markitai / pip install -U markitai.

Added ​

  • markitai update updates a setup-script install in place (other installs get their command) and offers to restart a running markitai serve.
  • A newer release is named once a day after a run, in the markitai serve startup lines and in doctor; update.check: false or MARKITAI_NO_UPDATE_CHECK=1 turns this off.
  • A markitai serve that listens only on this computer can be updated from the workbench (an icon beside the sidebar toggle) and comes back on the same address with the same access token.

Changed ​

  • The setup scripts suggest markitai update for updating.

Fixed ​

  • Data saved by a newer markitai is refused, not misread or replaced, when going back to an older version.
  • markitai serve no longer deletes users' saved models when its access-token file cannot be read.

[2.1.0] - 2026-10-25 ​

Adds Kimi Code, a few workbench features and fixes for 2.0.0. Update the way you installed: run the setup command again, or uv tool upgrade markitai / pip install -U markitai.

Added ​

  • Kimi Code provider (kimi_code/<model>), sharing one Kimi card with the Kimi platform in the workbench.
  • Workbench: a job's Details show its options as a command line; a select-all box in the job list; Ctrl/⌘+B folds the sidebar.

Changed ​

  • markitai serve prints its startup lines in the terminal's language. Scripts reading the English lines should read the address and the token value instead.
  • Workbench: conversion options open in a dialog, help moved into (i) icons, a tighter properties panel, and Docs opens markitai.dev.

Fixed ​

  • image.format: webp writes lossy WebP again, as 1.2 did.
  • A Chinese terminal shows Chinese for every command.
  • Kimi platform keys from the China region work.
  • LLM cleanup no longer pays for a second request when a small model drops markers.
  • Text that wraps inside a PDF table cell no longer becomes <br>.
  • A secret in a URL no longer appears in file names or history; short keys are hidden in error messages too.
  • The Windows setup script keeps the user Path's registry type.
  • Workbench display fixes (Compare outlines, the config editor caret, model lists).

[2.0.0] - 2026-10-24 ​

Markitai 2.0.0 is the first release of the Rust rewrite, and it replaces Python markitai 1.2.0. The commands (markitai, mkai, markitai-mcp), options and configuration stay the same. They now run as one native program: no Python, Node.js or Go runtime is needed. Most documents convert faster, use less memory and come out better.

Install or upgrade ​

  • macOS, Linux: curl -fsSL https://markitai.dev/setup.sh | sh
  • Windows (PowerShell): powershell -ExecutionPolicy ByPass -c "irm https://markitai.dev/setup.ps1 | iex"
  • The setup scripts install this machine's release archive for the current user, after checking it against the release's SHA256SUMS. Once the new release is in place, they remove a markitai 1.x that uv tool installed. Run the same command again to update; add --uninstall (Windows: -Uninstall) to remove it.
  • With Python: uv tool upgrade markitai or pip install -U markitai (Python 3.10 or later). The wheel carries the same program and all three commands.
  • Platforms: macOS 11+ (Apple silicon, Intel), Linux with glibc 2.28+ (x86-64, ARM64), Windows (x64, ARM64). Each archive is listed in SHA256SUMS and has a build attestation (gh attestation verify <file> --repo Ynewtime/markitai).
  • ~/.markitai (configuration, .env, caches, history) is kept and read as before. A 1.2 configuration file loads.
  • To go back to 1.2, see rolling back.

What is new ​

  • One core everywhere: the CLI, the markitai serve workbench, the MCP server and in-process Python, Node.js and Go bindings.
  • Workbench, redesigned: in Chinese, English and Japanese. Compare shows the original beside its Markdown, Diff compares base and LLM output, and math and Mermaid diagrams are drawn. History can be converted again. User tokens can keep their own models.
  • PDF: a text layer is checked before it is trusted. Garbled layers go to OCR, and hidden text is left out (security.pdf_sanitize). Each page says where its text came from. CJK, right-to-left text, tables and charts are handled better.
  • Local OCR: the portable PaddleOCR engine is the default on every system (macOS Vision is opt-in). markitai doctor --fix installs the models.
  • Formats: new readers for RST, Org, TeX, Outlook MSG and Numbers, and HEIC and AVIF decode on every system. Spreadsheets, legacy Office, EPUB and HTML convert better.
  • Output: -f json writes versioned document JSON, and --text-only writes an image or PDF as just the text recognised in it.
  • Models: 95 provider routes, including Bedrock and Vertex AI without their SDKs. --llm-batch also works with Anthropic Message Batches. Image answers are cached, and the prices of common models are checked against the providers' pricing pages. A batch that 1.2 left pending is collected after the upgrade.

Changes that can break scripts ​

  • Page and slide markers are off by default; --page-markers keeps them.
  • markitai serve asks for its token from the same computer too (Authorization: Bearer <token>).
  • Remote fetch services are opt-in. To keep 1.2's fallback, set fetch.remote_consent: always.
  • A usage error exits 2, and ✓ Wrote … goes to stderr.
  • Batch-only options (--resume, -g, --max-depth, --llm-batch) stop a run on a single file.
  • Extracted pictures are named by their content (.markitai/assets/<hash>.jpg). With image.compress on (the default), PNG and HEIC pictures become .jpg.
  • A folder batch skips symbolic links and names them (output.allow_symlinks: true follows them).
  • ChatGPT subscriptions go through the official Codex CLI: run markitai auth chatgpt login once.
  • The full list is in compatibility.

Removed ​

  • markitai doctor --timings and --suggest-extras, MARKITAI_PDF_WORKERS, MARKITAI_STATIC_HTTP, and inline terminal images (image.stdout_fetch_external).
  • batch.heavy_task_limit is accepted with a warning and does nothing; use -j.
  • Microsoft Office as a page renderer (office.macos_fallback is accepted with a warning): only LibreOffice draws Office pages.
  • ChatGPT sign-in through LiteLLM's device-code login.
  • The Python extras (markitai[all], [ocr], …) are kept as names but add nothing.

Known issues ​

  • macOS 15 with Vision (opt-in): under the default language, Vision may label Japanese text zh-Hans and read it as Chinese. Set ocr.lang: ja for Japanese images. The default portable engine is not affected.
  • image.format: webp below quality 100: there is no lossy WebP encoder yet. Pictures without transparency are written as JPEG (.jpg, one warning). Pictures with transparency, and anything at quality 100, are written as lossless WebP, which is larger.
  • PyPI has wheels only, for the six platforms above, and no 1.x any more. On other platforms pip has nothing to install and leaves an installed 1.x as it is. The source archive on this Release builds with Rust 1.92 or later.
  • Builds are not signed or notarized. macOS Gatekeeper blocks an archive downloaded with a browser (what to do); the setup script is not affected.

About these notes ​

The detailed notes in the changelog compare 2.0.0 with Python markitai 1.2.0 and with the 2.0 development builds; many entries under Fixed concern only those builds. Where an entry says "Python markitai 1.3.0", it means the Python development line after 1.2.0. That line was never published; 2.0.0 takes its place.

Added ​

  • The workbench's Convert options offer what the command line already could (contract v1.20, each shown when the server announces it): a { } JSON switch after the Output profiles (format: "json" in job options, features.document_json) saves each result's document JSON as <name>.json beside its Markdown, in its files, its .zip and the command line (-f json); while OCR is on and LLM off, a picker after OCR (Local · Automatic language) chooses the local OCR language (ocr_lang, from the capabilities' ocr_languages: automatic, Simplified and Traditional Chinese, Japanese, Korean, Russian, Arabic, Thai, Hindi, Greek, plus a configured ocr.lang), replacing the note about the local engine; Advanced gains a Pages row with Page Markers (page_markers, --page-markers) and OCR Every Page (ocr_all_pages, ocr.per_page_routing: false). All four are saved defaults too (output.format, ocr.lang, ocr.per_page_routing, output.page_markers), and the OCR settings appear in the command line as --config-json '{"ocr":{…}}'.

  • The workbench's Download in a conversion's window is a split button: it saves the Markdown, and its caret lists Markdown .md, JSON .json, All files .zip and Choose files… (on a phone, in "⋯"); the item menu's JSON entry stays.

  • The workbench words two notices Python markitai now sends in each interface language: the first conversion after an install, while the conversion libraries load once, and the first OCR use, while the OCR libraries and models load once.

  • PDF with --ocr: a text layer the text-only checks trust is also compared with what its glyphs draw, as Python markitai 1.3.0 does. A few stretches of each font's text (two per font per document, four crops a page at most) are drawn from the page and read by the OCR recognizer alone; a page whose fonts disagree is read by OCR instead, with the page source text layer failed the script check: the layer's text is not what its glyphs draw (font …) or the layer reads Latin, the page shows CJK (font …). A shifted alphabet (cqi luvvoccii for the committee) and Latin codes behind CJK glyphs now come out as the text the page shows. A non-embedded CJK font is drawn from its character collection for the check and for the OCR of such a page, not through the map being checked. It costs about 0.1–0.3 s per document under --ocr (mostly loading the recognizer once) and nothing without it.

  • With an empty llm.model_list and no MODEL, subscription runtimes signed in on this machine join the automatic model pool before the API keys, as in Python markitai: Claude Code (claude-agent/haiku), Copilot (copilot/auto, letting Copilot choose) and ChatGPT through Codex (the lightest current model Codex lists, gpt-6-luna today). Each runtime is asked for its own sign-in status once per process; a run that spreads documents over several detected providers says so once. markitai init offers the same models (leaving out the Anthropic key's model when Claude Code serves Claude Haiku on a subscription) and tells how to sign in to an installed runtime that is not signed in.

  • markitai doctor lists provider keys that are set but used by no active model (Not used: OPENAI_API_KEY (set, but no model in llm.model_list uses it); unused_api_keys in --json), as Python markitai does.

  • Image analysis answers (--alt/--desc) are cached in cache.db like document answers, as Python markitai caches them, so a re-run sends nothing and costs nothing for pictures it has seen: the key is the pictures' bytes, the document context, the image prompts and the pool's vision models (not the file name, so the same picture elsewhere is reused).

  • API keys are also read from GOOGLE_API_KEY (before GEMINI_API_KEY, as Google's SDK reads it), ANTHROPIC_AUTH_TOKEN (a gateway token, sent as Authorization: Bearer) and OR_API_KEY, the names LiteLLM and Python markitai read; each also lets its provider join the automatic model pool.

  • The Python wheel installs the commands markitai, mkai and markitai-mcp (and runs python -m markitai), as the Python markitai package did, so pip install markitai gives the command line again: each runs the same program as the markitai executable, which the wheel's native extension now carries (the command-line crate is a library the executables and the extension share), and ends with its exit status. Python markitai's extras (ocr, serve, mcp, claude-agent, copilot, browser, extra-fetch, svg, heif, legacy, all) are kept as names that add nothing, so pip install "markitai[serve]" still resolves.

  • Python API: markitai.ConfigFileError (also in markitai.api), Python markitai's error for a bad configuration, is raised for one; it is a ValueError subclass, so except ValueError keeps working. await markitai.fetch.close_shared_clients() (a no-op) and markitai.utils.shutdown.finalize_process(code) (flushes both streams and ends the process with code) can be imported as before.

  • --text-only (output.text_only; the workbench's Text only under Other, text_only in job options with features.text_only) writes an image or a PDF as only the text recognised in it: no front matter, title heading, picture link, image description or code fence. Local OCR gives its text; a vision model gives its literal transcription (the image analysis's extracted text, requested even without alt or descriptions); a PDF keeps its text's headings and tables and loses its pictures and page-image comments. No picture is published for it, and an image without readable text gives an empty document and a warning.

  • A Word or PowerPoint chart stored as an EMF or WMF picture is now drawn as a PNG by LibreOffice, when it is installed (a separate process with a time limit and size limits, cropped to the drawing), so that it is compressed and analysed like any picture; without LibreOffice the file is kept as before, with no notice.

  • The workbench's item menu has a JSON entry (shown when the server announces document_json, contract v1.19) that saves the document markitai -f json writes.

  • -f json (--format json, output.format) writes each converted document as JSON with stable, versioned fields: schema_version 1.0, tool (version, commit), source (path or URL, type, size, SHA-256), title, properties (the front matter), markdown.base/markdown.llm, plain text, pages (number, text, Markdown, pictures, screenshot), images (path, alt, description, text read in it, page), tables (header and rows), notices (a stable code, level, the message and the pages, pictures or sheets it names), usage and timings. Every key is always present (null when it does not apply) and 1.x versions only add keys; docs/json-output.md and docs/schema/document-v1.schema.json describe it, and tests pin the field set. Without -o the document goes to stdout; with -o it is written beside each Markdown file under the same name (-o x.json names it), and a batch adds markitai.index.json listing every item. markitai serve serves each finished item's document at GET /api/jobs/{job_id}/items/{item_id}/document (features.document_json).

  • A subscription provider whose official runtime cannot list models says why: POST /api/settings/llm/model-discovery (and the user route) answers reason runtime_missing, runtime_unsupported, not_signed_in or runtime_failed (also timeout) with runtime (name, required, installed) and a next_step (install with the pinned npm install -g … command, or sign_in with markitai auth <provider> login), for Claude, Copilot and ChatGPT alike, instead of provider_error for every case. The workbench shows the cause, the command with a copy button and the setup guide, and switches off Add a model by ID for a missing, unsupported or signed-out runtime (a failing one only cautions). Workbench contract v1.18.

  • The workbench's submission header has a larger chevron whose whole leading cell (32 × 38 px, 40 × 44 on a phone) opens and closes it.

  • The workbench draws mathematics: $…$ inline and $$…$$ display formulas (aligned environments included) in Rendered, in headings and in the Contents list. KaTeX 0.19.0 is bundled into a separate math.js with its stylesheet and woff2 fonts, all served by the binary and loaded only when a document holds a formula; a formula that cannot be drawn stays as its source, and prices such as $5 and $6 stay text.

  • The workbench's Conversions list can convert again: Re-convert beside Delete in the selection bar (and in a conversion's ⋯ menu) takes the selected kept originals back to the Convert page (uploads are read from the server, pages are re-added by address), opens the options and converts them as a new batch that leaves the first results alone; originals no longer kept are named, and a missing model is said at once.

  • A warning mark in the Conversions list shows its notices on hover, focus or tap.

  • The workbench's Compare gives a PDF a viewer's toolbar: zoom (Fit width, Fit page, 50–200 %, Ctrl/⌘ + − 0 over the pane), previous and next page with a page box, and an outline panel with the PDF's bookmarks, else the Markdown's headings, else page thumbnails; blocks stay linked at every zoom, and a long PDF's page text is read in the background so linking reaches its last page (it no longer wraps back to an earlier page that says the same). markitai serve adds GET …/original/outline (features.pdf_outline) with the PDF's bookmarks as {items: [{title, page, level}], truncated}.

  • markitai serve gives the workbench's composer its defaults: GET /api/convert-defaults answers the server's (its configuration: llm.enabled, ocr.enabled, image.alt_enabled, image.desc_enabled, screenshot.enabled, screenshot.screenshot_only, llm.pure, cache.no_cache, image.compress, output.profile, fetch.strategy) and, for a user token, its own over them; PUT /api/convert-defaults {expected_revision, defaults} saves the admin's into config.json (only keys the file has or values the built-in defaults do not give) and a user's into its own file (null follows the server's again); Cloudflare is never a default (422 invalid_convert_defaults), a stale revision is 409 (features.convert_defaults). Workbench contract v1.17.

  • The workbench's Convert options start from those defaults, say whether the current choices are the defaults, and offer Reset to defaults and Save as default (a user token also Use the server's defaults).

  • The workbench edits a model's routing weight in place (click its pill, type, Enter or leaving the field saves, Escape cancels) on the admin's models and a user's own; a user's shared models get Test and a weight of the user's own ("Use the admin's 2" follows the admin again); a user token gets Edit config for its own models; the sidebar's version line reads 2.0.0-dev 36583fe 10-14 22:48 (version, commit and build time in the viewer's time zone, -dirty for a build with uncommitted changes), without the product name.

  • markitai serve lets a user token weigh and test the admin's shared models and edit its own models as text: PATCH /api/me/models/shared/{id} {expected_revision, weight} sets that user's own weight for a shared model (0 turns it off for that user alone; null or the admin's weight follows the admin again; SharedModel.default_weight is the admin's), POST /api/me/models/test {shared_id} tests a shared model on the server with the admin's settings and answers only whether it passed and why not (one test at a time and six a minute per user, else 429 provider_busy), and GET/PUT /api/me/config (features.user_config) read and save the user's models, connections and shared-model weights as one JSON document, keys masked and kept when left as they are, with the settings' revision protocol and the user rules (never the server's config.json). Capabilities gain build (version, commit, built_at, dirty), and the static workbench policy allows media-src https:. Workbench contract v1.16.

  • markitai serve keeps models of a user token's own and lets the admin share models with users. A user token manages its own models through /api/me/models* (the same routes, bodies and revision protocol as /api/settings/llm*; anyone else gets 403 user_token_required): they are kept for that token alone in MARKITAI_HOME/serve/user-models/<id>.json (owner-only, replaced atomically, never in config.json), keys are only ever shown masked, a model needs the user's own key or an address of its own that is not the server's machine (422 key_or_address_required, server_address), providers that would sign in with the server's own account or cloud login are refused (422 server_credentials), and a token keeps at most 50 (409 model_limit). An admin model marked "model_info": {"share": "shared"} (share on PATCH /api/settings/llm/deployments/{id}, shown on every saved row) also serves users' conversions, never any other route; users see only its model ID, provider and weight, and a failure of it never names where it is served. A user's conversions route over its own models and the shared ones as one pool by weight and never use the server's environment keys or detected models; with neither, a request for model processing is 422 llm_unavailable. Revoking a token deletes its models; GET /api/access-tokens reports each token's model_count; capabilities report features.user_models and, for a user, the pool. Workbench contract v1.15.

  • The workbench's Settings show a user token its own Models page (Add models, Test, Edit, Delete and weights, as the admin's) with the admin's shared models below it, read-only; the admin's saved models get a Private | Shared toggle, the Access token list says how many models each user token keeps and Revoke says they are deleted with it, and the Convert page says where to add a model when none can serve the caller.

  • markitai serve records how a web page looked when it was captured: the original descriptor's capture says, for the kept page and for its screenshots, whether each showed the content (a picture or a player with a short caption counts), a blank page (or only a request for JavaScript) or a verification, captcha or login page instead ({state, text_length, title, reason}), and GET …/original/blocks gives the boxes of the page's blocks where the browser drew them when it took the screenshots (blocks in the descriptor). A page the browser loads in a service job also gets compare screenshots when none were asked for (JPEG tiles beside the job, at most 20, never part of the result or sent to a model). Workbench contract v1.14.

  • The workbench's Compare links the Markdown with a web page's screenshot too: hovering a block outlines it on the screenshot (and back), from the boxes the server recorded.

  • markitai serve access levels and Edit config. The server token is the admin's; an admin creates labelled user tokens in the workbench (GET/POST /api/access-tokens, DELETE /api/access-tokens/{id}; at most 50, each shown once and kept only as its SHA-256 in MARKITAI_HOME/serve/access.json, so they survive restarts and token regeneration). A user token reaches everything but the settings and the token routes (403 settings_forbidden) and sees only the jobs it created (any other is 404 job_not_found, in history, archives, tickets and deletions too); revoking one stops its requests, download tickets and event streams at once. GET/PUT /api/settings/config shows the configuration file's own text with every secret masked and saves the edited text exactly as written (the workbench sends it back with the file's own line endings, so on Windows a CRLF file keeps CRLF, Review shows only real changes and unchanged lines stay byte-identical; a masked value kept in place is put back as the stored secret; a stale revision is 409, an invalid file 422 invalid_config), applying models and providers at once and reporting restart_required for the rest. Capabilities report access (role, label), features.user_tokens and features.config_edit.

  • POST /api/history/delete deletes several saved jobs and items in one request ({jobs, items}, 1 to 1,000 entries), each exactly as its single route would, and answers every entry's outcome in order (features.batch_delete).

  • The workbench's Conversions list deletes several conversions at once: a box at the left of every row and submission header, Space/Shift/Ctrl+A/Escape/Delete on the keyboard, a bar with the count, Select all, Clear and Delete (asked first, saying the files go too), Select mode on phones; on a backend without the batch route it deletes one by one, four at a time, and names what failed. Settings has Edit config (admin): the configuration file as text with secrets masked, checked as JSON while typed, a diff before saving, saved against the revision it was opened at.

  • markitai serve's GET …/original/pages/{n}/text also lists where the page's pictures are drawn (images, boxes in the same frame as its lines), so the workbench's Compare can outline a PDF's picture on its page.

  • markitai serve: GET /api/jobs/{job_id}/items/{item_id}/archive zips one item's result files and its kept original; GET …/original/view sends a kept web page for a sandboxed frame (one <base> inserted, a policy that allows no script, form or remote load, framed only by the service's own pages; opened with a download ticket); POST /api/access-token/regenerate replaces the access token, revoking the old one, its download tickets and its open event streams. Capabilities report features.item_archive and features.token_regenerate; the static pages allow frame-src 'self'; a URL item's screenshots are also offered beside its kept page.

  • HEIF/HEIC images decode on Windows and Linux with a built-in pure-Rust decoder (heifer), for conversion, vision models and local OCR, as on macOS; they were an unsupported error before.

  • AVIF images decode on Windows and Linux with rav1d, the Rust port of the dav1d AV1 decoder (vendored, without assembly), for conversion, embedded images, vision models and local OCR, as on macOS; they were an unsupported error before. Grids, alpha planes, 10/12-bit and the crop, rotation and mirror properties are read; overlays are an explicit unsupported error, and a decoder failure fails only that image. One file decodes at most 96 million pixels and 2,050 AV1 items in all, so a 3 KB grid that names one 30-megapixel tile 1,024 times fails in about a second instead of decoding for minutes. The decoder adds about 1.2 MB to the release CLI.

  • markitai serve shows an item's original beside its Markdown: GET /api/jobs/{job_id}/items/{item_id}/original describes what is kept (an uploaded PDF, image, HTML or text file, a URL item's page as converted from HTML, or else the page screenshots) with its page boxes, …/original/file downloads it (HTML and SVG as plain text, sandboxed), …/original/pages/{n} draws a PDF page or scales an image (TIFF and HEIC/HEIF as PNG), and …/original/pages/{n}/text gives a PDF page's text lines with their boxes. At most two pages are drawn at a time (429 render_busy).

  • Service jobs take an optional label, returned in snapshots and history; GET /api/history?include=items returns each job's items with its summary; …/result?max_bytes= returns the start of a long result, cut at a line, with truncated and markdown_bytes; items report models and source_size; capabilities report implementation, docs_url, source_url and features.

  • The workbench reads an uploaded .urls file as a URL list: each entry becomes a URL item (comments and blank lines skipped, an entry that names its output keeps that name), instead of failing as an unsupported document. A batch — a folder, several chosen files, a drop that reached into a folder, or a .urls list — now waits for Convert with one staged line naming it, so its options can be chosen first; a single file still starts at once. Such a submission is one header row in the conversions list, named after the folder or list, with its items under it.

  • --help ends with the build identity of the binary: its version, the commit it came from (marked (local changes) when the worktree differed, unknown outside a checkout), the target and profile, and the time the build ran in the reader's own time zone with its UTC offset. SOURCE_DATE_EPOCH fixes that time for reproducible builds; -h and the one-line --version are unchanged.

  • Native Rust conversion core, standalone CLI, and in-process Python, Node.js and Go bindings. CLI packages need no Python, Node.js or Go runtime.

  • Windows x64/ARM64 ZIP and macOS/Linux CLI archives with command aliases, dependency licenses and offline Markdown guides, including llms.txt and llms-full.txt. Optional static Go packages support macOS arm64 and Linux x86-64; Windows Go/cgo distribution remains unvalidated.

  • Embedded markitai serve workbench with Chinese, English and Japanese, light/dark themes, responsive layouts, file/folder/URL submission, upload progress and cancellation, result previews, base/enhanced differences, history, retries and downloads.

  • The workbench's Rendered view draws the inline SVG charts and diagrams a converted web page keeps as markup, instead of an empty block. Each drawing is sanitized and kept to itself: drawing properties from style become attributes, ids get a prefix of their drawing, href, xlink:href and url() resolve only to elements of the same drawing, and images, foreignObject, style sheets, scripts, animations and use of other documents are removed, so a drawing loads, runs and reaches nothing; unpainted labels take the text colour and read in dark mode.

  • The workbench's Preview shows a document's contents when it has four or more headings: its top three heading levels beside the document on a wide window, marking the section being read as it scrolls, and a sheet from a round button on a phone or a narrow window. A button beside Base | LLM hides or shows them, remembered in this browser.

  • REST conversion jobs, event streams and OpenAPI documentation; native stdio MCP conversion tools and bounded batch jobs through markitai mcp or markitai-mcp.

  • Native RST, Org, TeX and Outlook MSG readers, notebook outputs, Numbers tables and directory packages, MIME-aware email attachments, and Office metadata extraction.

  • Local image/PDF OCR with macOS Vision or portable PaddleOCR, with explicit language selection and English/Chinese/Japanese/Korean default routing. Portable models install separately with integrity checks; doctor inspects them and doctor --fix repairs them.

  • PDF screenshots and scanned-page OCR on Windows/Linux through the in-process hayro renderer; macOS uses CoreGraphics by default. Optional LibreOffice export adds Office screenshots and OCR. Multi-page TIFF, SVG vision previews and macOS HEIF/AVIF decoding are also supported.

  • PDF hidden-text policy security.pdf_sanitize with off, warn and remove modes. Text a reader cannot see is left out of the extracted body by default and quoted in a notice; original assets remain intact, and incomplete inspection is reported.

  • Optional isolated Chromium fetching, screenshots, origin-scoped Basic/Digest authentication and authenticated PDF downloads; explicit browser installation through doctor --fix, reusable isolated sessions and operating-system manual proxy discovery.

  • Cloudflare URL/file conversion and Jina/Defuddle fetch fallbacks, subject to remote-processing consent and local-only policies. The workbench requests consent per operation; privacy notices identify off-device image and URL processing.

  • Weighted model routing, fallback groups, retries, concurrency and request budgets, custom prompts, structured document metadata and image descriptions. Persistent fetch/LLM caches and identical-request coalescing avoid repeated work.

  • Additional OpenAI-compatible providers, model discovery and connection probes, plus optional Copilot, Claude and ChatGPT subscription conversion through supported official runtimes. Subscription authentication status and delegated login are available.

  • The other OpenAI-compatible prefixes Python markitai reaches through LiteLLM are routed too (69 more, from ai21/ and volcengine/ to cohere/, huggingface/, bedrock_mantle/, cloudflare/, databricks/, llamafile/ and docker_model_runner/), with LiteLLM's model naming and key variables. Each base URL was checked against the provider's documentation on 2026-10-11, and where a provider documents a newer host or key name than LiteLLM, the documented one is used or read first. huggingface/<provider>/<org>/<model> asks the router for that provider, bedrock_mantle/<region>/<model> and the AWS region variables choose Bedrock's region, and Workers AI takes CLOUDFLARE_ACCOUNT_ID. Prefixes of services that ended (GitHub Models, Meta's Llama API, Lambda and Hyperbolic inference, and others) are refused with the reason instead of a generic error.

  • bedrock/<model> and vertex_ai/<model> work without their SDKs, as Python markitai's do through LiteLLM. Bedrock goes through the Converse API for every chat model (foundation models, inference profiles, ARNs), with a Bedrock API key or AWS keys signed with Signature Version 4, found in the AWS SDKs' order: the deployment's api_key, AWS_BEARER_TOKEN_BEDROCK, the AWS key variables and ~/.aws profiles (static keys, credential_process, and SSO or role profiles through the AWS CLI). Vertex AI serves Gemini and Model Garden models through its OpenAI-compatible endpoint and Claude through rawPredict, with access tokens minted from a service account, user credentials or gcloud's application default credentials, or taken from the gcloud CLI. Both were checked offline (the AWS signing test suite, curl's own signer, local token endpoints); their costs are not in the pricing catalog yet.

  • OpenAI text Batch submission, collection and restart continuation with private request records, reconciliation and output ownership checks. Observed usage, unknown pricing and failed-attempt costs remain distinguishable.

  • --llm-batch also submits to Anthropic Message Batches for an anthropic/<model> deployment, as Python markitai does: the same submit, wait, --llm-batch-collect, --resume and llm.on_failure handling, with the live request's structured mode and batch-rate costs. The requests travel inline in the one create call, after checking them against their saved digest; each custom ID carries the submission nonce, so an uncertain creation is reconciled by opening the results of ended batches of the same size, never by creating again. Errored, canceled and expired requests are item failures without usage, as Anthropic does not bill them.

  • A batch Python markitai 1.2.x submitted and left pending is collected after upgrading: --llm-batch-collect ID -o DIR (or --llm-batch --resume -o DIR for the only one) reads Python's .markitai/batch-<ID>/ state without changing it, turns it into a native job for the same OpenAI or Anthropic model, and publishes the .llm.md files from the answers Python paid for. Python's image and page/slide placeholders are recomputed with its own rules (checked against its saved requests) and must all come back once; --alt captions and --desc descriptions of that run are applied as Python would. A normal --llm-batch run into such a directory names the pending batch and its collection command.

  • Collecting a provider batch prints each document's warnings (a base kept after a failed answer, an image analysis that failed), as Python does; the run report still leaves warnings out.

  • CLI reports, optional history, resumable directory/URL-list batches, controlled interruption, publication locks and recovery receipts. Shared assets and stdout image assets persist under isolated Markitai state.

  • Chinese/English CLI help and guided conversion, configuration initialization/editing/validation, capability diagnostics and private rotating logs. Windows supports native console handling, subscription command shims, recovery and process-tree cleanup.

  • markitai serve serves an item's own extracted images for display: GET /api/jobs/{job_id}/items/{item_id}/assets/{relpath} sends only a PNG, JPEG, GIF, WebP or AVIF that the item's result lists, inline and sandboxed, with an ETag (a matching If-None-Match is 304); anything else, another item's image or a path leaving the job is 404 file_not_found. Capabilities announce features.item_assets. The workbench reads preview images through it and now shows the pictures in the inspector's "Start of the result" too, where every picture used to read "[Image: preview unavailable]"; an image reference whose folder does not match (an e-book's own ../Images/x.png, a model's assets/x.png) resolves by file name to the item's one listed image of that name.

  • A PDF text layer that is there but wrong is caught. Consonant soup, undefined glyph codes and enciphered text were caught before; now also a font that maps its letters to private-use, replacement or control codes or to raw (cid:NN) codes, UTF-8 read as Windows-1252 (café), and right-to-left Hebrew or Arabic stored in reverse order. Such pages are handled like a scanned page: their text is not used, they are listed with the scanned-page notice and in unread_pages, and --ocr (or the vision model) reads them from the page image instead. A page in a script the configured ocr.lang does not read keeps its text layer, and a warning says which ocr.lang would read it (PDF page 1 (1 of 1): the text layer reads wrong (right-to-left text in reverse order); consider re-running with --ocr and ocr.lang set to ar.). Chinese, Japanese, English, code, table and math pages are left alone, their Markdown unchanged. The idea of checking the text layer this way comes from Indra OCR; the code is markitai's own.

  • Each PDF page says where its text came from. page_sources lists the pages as {"page", "source", "reason"}: source is text (the text layer), ocr or mixed (the text layer with its pictures OCR'd, or the vision model reading the page image beside it under --ocr --llm), and reason a short note such as text layer trusted, no text layer, text layer failed the script check: … or OCR forced by --ocr (ocr.per_page_routing is off). It is in --json items, the bindings' results, recorded history and markitai serve items (an addition the workbench ignores).

  • The workbench draws Mermaid diagrams: a fenced block marked mermaid shows as its diagram in Rendered, Compare and the Details excerpt (every diagram type of Mermaid 12.1, drawn by Pele 1.x, which follows upstream releases, in a separate /ui/mermaid.js fetched only when a preview holds one), in the theme's colours, which follow a theme switch. Source, the downloads and the Markdown keep the code. The drawing is rebuilt from an allowlist of SVG elements and attributes under the unchanged Content Security Policy; its links open in a new tab without a referrer and it loads no pictures. A block that does not parse, holds an unknown diagram type or is too large (over 50,000 characters or 2,000 flowchart edges) stays a code block with a note saying why.

  • Setup scripts install a release without Python: https://markitai.dev/setup.sh (macOS, Linux) and https://markitai.dev/setup.ps1 (Windows PowerShell 5.1 and PowerShell 7, x64 and ARM64) download the latest release, or MARKITAI_VERSION, check the archive against the release's SHA256SUMS and install markitai, mkai and markitai-mcp for the current user (~/.local/bin, %LOCALAPPDATA%\Programs\markitai added to the user Path). The new files are staged first; only then is a Python markitai 1.x that uv tool installed removed, and a failed Windows update puts the previous release back. A receipt lists what they installed, so an update or --uninstall (-Uninstall) removes only those files and the Path entry setup added; a custom MARKITAI_INSTALL_DIR is reused by later runs; ~/.markitai is kept. With a terminal they offer markitai doctor --fix. A failure ends with setup's own message and exit code 1, without PowerShell's error record; pasted into an open window, setup leaves it open.

  • Each GitHub Release carries one CLI archive per platform (markitai-v<version>-<target>.tar.gz, .zip on Windows) for macOS 11+ (Apple silicon, Intel), Linux with glibc 2.28+ (x86-64, ARM64) and Windows (x64, ARM64), the six wheels and the source distribution, a SHA256SUMS over every asset and build-provenance attestations (gh attestation verify <file> --repo Ynewtime/markitai). Archives and wheels carry the same license set, the workbench's web libraries and fonts included.

  • PyPI has abi3 wheels (CPython 3.10 or later, 3.14 included) for the same six platforms, and no source distribution: on any other platform pip has nothing to install (PyPI no longer carries 1.x) rather than compiling 2.0; the Release's source archive builds with Rust 1.92 or later.

Changed ​

  • The workbench's Images option Description JSON is now called Image Descriptions (zh 图片描述, ja 画像の説明): it describes pictures and is not the document JSON.

  • The static fetch identifies itself as markitai/<version> (+https://github.com/Ynewtime/markitai), with the contact address sites' User-Agent policies ask for (Wikimedia), as Python markitai does, and asks a page answered with 401 or 403 once more with a desktop Chrome's headers before auto starts the local browser for it.

  • The local browser's render wait ends once the page has settled, as in Python markitai: fetch.playwright.extra_wait_ms (3 s) is now an upper bound that ends when no document, script, XHR or fetch request has been in flight and the DOM has not changed for 0.5 s (pictures, fonts, media and event streams are not waited for); a page that fits the window is not scrolled, and the paint pause after scrolling is kept for screenshots only. A page complete at load is read about 4 seconds sooner.

  • A fetched URL's response may be as large as a local input file, 500 MiB (it was 100 MiB), as Python markitai allows; a document the local browser downloads stays at 100 MiB.

  • Hidden rows and columns of XLSX, XLSM, XLSB and XLS workbooks are written like visible ones, with their cell notes and pictures, as Python markitai 1.3.0 writes them and as hidden sheets already were, instead of being left out with a warning per sheet; the item gets one warning with the count (The workbook includes 1 hidden row and 2 hidden columns holding content; they are converted like the visible ones.).

  • A strategy chosen with -s defuddle, -s jina or -s cloudflare (or a service job's strategy) that is refused or fails no longer ends the conversion: the page is read locally (static, then the browser) with a warning naming the service and why, and no other remote service is asked; when the local fetch fails too, both failures are named.

  • Remote fetching stays opt-in (the default fetch.remote_consent keeps auto local, unlike Python markitai's): every auto failure a remote reader could have repaired now ends with how to allow one, set fetch.remote_consent: always (markitai config set fetch.remote_consent always), not only a verification page. docs/compatibility.md has the migration note.

  • A serve job a user token owns never uses a remote service, whatever fetch.remote_consent says, as in Python markitai 1.3.0: strategy defuddle, jina or cloudflare and backend: cloudflare are 403 remote_processing_forbidden, checked by the job's owner (an admin's retry of a user's job too); a remote server default runs as auto for it (GET /api/convert-defaults shows auto to a user, a user's PUT naming one is 422 invalid_convert_defaults); auto leaves its remote fallbacks out and a page the local strategies could not read says so. The workbench offers a user token only Auto, Static and Playwright and no backend choice.

  • History meta.json items always carry pricing (null when no request was observed) and models (the models that answered an enhanced result's requests; [] for a base-only result), as Python markitai writes them.

  • The MCP tools describe themselves fully, as Python markitai's do: what each argument does (flags that follow the configuration, what llm needs and may cost, OCR and screenshot backends, profiles, concurrency), what the result's fields mean (preview and markdown_file, cost_usd and pricing, warnings worth acting on, done/failed/finished) and how a call fails.

  • MCP job_status reports done as the number of items that succeeded, as markitai serve and Python markitai do, and adds finished (succeeded plus failed) for progress; done used to count failed items too.

  • A folder batch still does not follow the symbolic links in the folder (output.allow_symlinks stays off by default), but it no longer skips them silently: a link to a document, a .urls list or a folder is counted and named in the closing summary (Skipped 2 items (symbolic links, not followed): …, with the setting that follows them) and listed in --json as a skipped item with skip_reason: "symlink". Python markitai followed these links.

  • --json follows Python markitai in three details: a single file that does not exist or has a format no reader handles rejects the run (top-level error, items: []) instead of reporting a failed item with error: null; a single file's source is its file name, as the report names it, not the path as typed; a failed item's duration_s is the time it took (was null).

  • chatgpt/<model> takes any model the installed Codex offers (gpt-6-luna, gpt-6-sol, gpt-6.1-sol, …), not only gpt-5.5: the adapter asks Codex for its model list (codex debug models), refuses a model Codex does not offer before any request, and sends each model with its own restricted catalog entry (patch tool, code mode, experimental tools, sub-agents and search off). Where a model is chosen for you, the default is the lightest current model Codex lists (the newest luna, else one Codex calls small or affordable, else its own default), since clean-up and picture text need no frontier model; name another as chatgpt/<model> to use it. A Codex that cannot list its models gets the named model with a one-time warning; this build no longer compiles in a gpt-5.5 catalog (nor ships licenses/codex/). Python markitai users: this build signs in through Codex (markitai auth chatgpt login), not LiteLLM's device code; the Python login is not reused.

  • Newer Copilot CLI, Claude Code and Codex releases than the verified ones (1.0.90-2, 2.1.284, 0.159.0) are used instead of refused, with a one-time warning in the first document's warnings, and doctor names the installed release; every message they send is still validated. Only an older release, or a Copilot runtime with another protocol than 3, is refused.

  • A batch's closing line says what became of every item, as Python markitai words it: Done: 2 of 8 files converted, 5 skipped, 1 failed (0:12) instead of Done: 2/8 files (in Chinese too).

  • When a model's answer loses one of the markers that keep pages, pictures, links and code exact, the error (and the resend that follows) now says what happened and names them, as Python markitai does: the answer dropped 1 of the 3 markers … (the start of page 2 …).

  • OpenAI GPT-5 and later models (including the default gpt-6-luna) get structured answers through response_format with the JSON schema, as Python markitai sends them, instead of JSON described in the prompt; a refused mode still falls back to JSON text. Gemini Flash-Lite and DeepSeek keep JSON text (not verified with a schema).

  • A Copilot request's time limit grows with its size as in Python markitai (60 s plus 1 s per 500 characters, half again with pictures, at most 600 s) instead of a flat 120 s; a longer configured router_settings.timeout still applies.

  • Models on a server you run (ollama/, ollama_chat/, lm_studio/, hosted_vllm/, llamafile/, docker_model_runner/, lemonade/, xinference/, oobabooga/) are priced at $0 instead of unknown: no more "Cost is incomplete" on every document, and llm.max_cost_per_document_usd no longer refuses them. Gateways (litellm_proxy/, openai_like/, openai/ with api_base) and other unpriced models still fail closed under a dollar cap.

  • Requests ask reasoning models not to think where it is safe, as Python markitai does: OpenAI models that accept reasoning_effort: "none" (gpt-6-luna, GPT-5.1 to 5.6) get it, and Claude Haiku 5.5 gets thinking: {"type": "disabled"} (DeepSeek already did). Without a configured max_tokens, a reviewed Anthropic model is asked for its output limit within the room its context leaves (as for Gemini and DeepSeek; at most 128,000) instead of 8,192; a configured cap above a reviewed model's limit is lowered to it; requests to OpenAI itself and GPT-5-or-later and o-series names (now including gpt-6-*) send max_completion_tokens.

  • HTTPS requests (URL fetches, model calls, model discovery and checks, image downloads, OCR model and browser downloads) trust the operating system's certificate store beside the Mozilla roots built into markitai, and, when SSL_CERT_FILE or SSL_CERT_DIR is set, the certificates they name in place of the system store. Behind a TLS-inspecting proxy whose authority is installed or named there, requests no longer fail with invalid peer certificate: UnknownIssuer; a variable naming nothing readable is reported on standard error, and the built-in roots still apply.

  • A routing weight is a whole number from 0 to 100 (0 = off): markitai serve answers 422 invalid_weight for any other value on every route that writes one (Edit config too, naming the entry; a weight a file already held may stay), and the workbench shows 0–100 in an empty weight field, says what is wrong under it, keeps the field open on Enter and keeps the arrow keys within the range.

  • Local OCR on macOS reads with the portable PP-OCR engine by default, like Windows and Linux; Vision is chosen with MARKITAI_OCR_BACKEND=vision and reads when the portable models cannot be downloaded (and the engine was not chosen). Vision's first use of a program compiles its models for about 20 seconds (a first scan in the workbench took 25 s), and its default reading of cjk-scanned.pdf ran lines together, flattened the table, lost a character and dropped the Korean line, which the portable engine reads in full; on the corpus scans the portable engine finds every expected line (Python's RapidOCR 9 of 10 on the CJK page) at about 0.6 s a page. The macOS build includes the portable engine through the default portable-media feature; PDF pages still render with CoreGraphics. MARKITAI_OCR_TRACE also times each Vision pass, and MARKITAI_OCR_VISION_POLICY=detect reads with one all-language request (for measuring). scripts/bench_ocr_backends.py compares the engines cold and warm.

  • The workbench shows a document's front matter as one light line above the body (a tag icon, the tags inline, Summary at its end), without a title of its own (the body's heading is the title); Summary opens a two-column properties table (summary, tags as chips, source, processed time and every other key) whose header folds it again, remembered for the tab's session, the same in Preview, Compare and the Details excerpt.

  • The workbench's cost line says what will happen while a conversion has no amount yet ("Counted once it starts", "Counting…", "Waiting for the model…", "No model requested"; after the end "None: no model answered", "None: no model ran" or "None: stopped before a model ran"), instead of "No model used" for a conversion still running with a model.

  • A conversion just submitted opens on Preview once it is converted (Details while it runs, and when it fails); the Details excerpt opens Preview. The sidebar's dot is grey and pulsing while something converts and green and still once everything is done (then it fades). Edit config colours the JSON in an editor that follows the theme.

  • Every table in the workbench's reading column takes the column's width and left edge; a wide one scrolls sideways inside its own box instead of reaching into the margins.

  • A PDF's pages without readable text are one warning per reason that names them as ranges and counts them against the document (PDF pages 3–5, 9 (4 of 120): native text was not recovered (scanned); OCR is required to read them. Run again with --ocr.), and a PDF with nine pages in ten or more without text gets one warning that says it is scanned (PDF is scanned: 488 of 491 pages have no readable text (pages 1–480, 483–490); run again with --ocr to read them.), instead of one warning per page in reports, --json and the service.

  • Rendered pages where local OCR finds no text are one warning that names them as ranges and counts them against the document (PDF pages 2, 245–246 (3 of 490): OCR found no text; they are blank in the output.; for Office documents Office pages 2, 5 (2 of 9): OCR found no text; the OCR supplement leaves them out.), instead of one PDF page 2: local OCR completed with no recognized text. per page.

  • A model answer that a safety check refused is explained in plain words: LLM enhancement failed: the model's answer left out or changed too much of the document, so it was not used; the converted Markdown is kept as it was. (and likewise for the other checks).

  • The workbench's Compare shows a web page's kept page first, else its screenshot, with Screenshot | Page only when both are usable; a kept page or screenshot that was a verification page or blank is not offered, and with neither the Original side says why and offers Open the page. A block the original side cannot show is no longer outlined.

  • The workbench's Diff is side by side on a wide window (base and LLM version with their line numbers, a change's lines facing each other, the changed words marked); narrow windows keep one column. In Diff the Base | LLM switch stays, with both on.

  • The workbench's notices are toast cards at the bottom (bottom right on wide screens, centred above the tab bar on phones) that never move the page, with a close button and folded details; errors stay until closed. Model tests, a saved configuration and failed actions use them instead of lines at the top of a page or window.

  • The workbench's conversion window has its tabs as Preview, Compare, Details and opens on Preview. The front-matter card is folded to one line (title · N tags) in Preview, Compare and Details. The reading column grows with the window (820 px up to 1120 px) and a table may take up to 200 px more on each side. The CLI line names staged files as ./name and a picked folder as ./folder/, with a note to run it in their folder. The Access token page drops "Use another token" (Sign out, then sign in), lists a new user token after Done (now at the right) and spaces its sections further apart.

  • markitai serve's policies: the static pages allow img-src 'self' blob: https: (remote HTTPS pictures; http: and data: stay refused), and a framed kept page (…/original/view) has sandbox allow-same-origin allow-popups allow-popups-to-escape-sandbox, so the workbench can read the page it shows. It stays script-less: there is no allow-scripts and no script-src, so default-src 'none' refuses every script, inline handler and javascript: URL. Every /api/settings/ route is admin only.

  • The workbench shows percent-encoded names decoded (20260314-%E5%B1%B1… reads 20260314-山…) in rows, the window title, Details and Files, keeping separators, controls and invisible marks escaped; pictures on the web (https://) show in Preview, Compare and Details, with the placeholder only when one fails to load; a web page's Compare links its blocks to the Markdown's by their words and outlines both, its Original header drops the download button and moves the frame's caption into an (i); Settings lists Appearance first; the Conversions summary has more room above the filters and a submission's chevron lines up with the status marks.

  • The workbench's conversion window no longer docks beside the list (the side panel was too cramped): it opens only as the centred window (full screen on phones), and its expand button or F grows it to the whole viewport and back, remembered per browser (a browser that chose the docked layout opens the window). Its Download opens Preview's Files, which lists the original (the upload, or the page as the server fetched it) and every result file, each with its own download, and Download all (.zip) for the item. Export PDF… in "⋯" opens a small card with its one choice, Markitai's own header and footer, explained under it, and Print…; the separate header-and-footer entry is gone, and "⋯" offers Open the page only where Compare's Original header does not.

  • The workbench's Compare shows a web page as it looked: its screenshot first when the conversion took one, and Page, the page as the server fetched it in a sandboxed frame with the site's own pictures, style sheets and fonts (…/original/view allows them over HTTPS: img-src https: data:; style-src https: 'unsafe-inline'; font-src https: data:; media-src https:) but none of its scripts and no form, instead of the page's text without its styles. Hovering a paragraph, list item, code block or a PDF's picture now outlines it on both sides too, not only headings and tables, and the outline is no longer cut at its edges.

  • Files chosen with the workbench's Upload or Folder, or dropped on it, wait in a staged list above the URL field, each with its own remove button (and Remove all), until Convert sends them with any typed URLs; a single file no longer starts a conversion at once.

  • The workbench's Preview: the contents list each heading's plain words (no inline <sup> tags or private-use glyph boxes, at most two lines), and the front-matter card leaves out a title the document repeats as its first # heading (no card when nothing else is left). A provider's settings page has clear sections, its model list is an inset panel with the search and Select all as its head, its breadcrumb is one size, and Access token uses the settings pages' centred column.

  • The workbench's Add models lists a provider once: the server's key for it and its connection at its own endpoint are one saved row that says how it authenticates (Using DEEPSEEK_API_KEY, Key saved, No key) and its model count, and Common and All mark a saved provider as Added (choosing it opens that row). A provider's key field says where else its key can come from (Or set MOONSHOT_API_KEY where markitai serve runs …) instead of a stray variable name above it.

  • The workbench's Access token page: Forget is now Sign out of this tab, and Regenerate token (asked first) replaces the server's token, so every other tab and device is signed out at once and must sign in again; the new token is shown once, with a sign-in link and its QR code for another device.

  • Local OCR with the portable engine (Windows, Linux) is about 18% faster on single files and 13% on a batch of scans: tract's operators (tract-core) are now optimized for speed within a size budget (opt-level = "s") instead of minimum size. The Linux arm64 release CLI grows by 4.7 MB (38.7 to 43.4 MB); full speed optimization (opt-level = 3) would add 2.3 MB more for less than one point more. Recognized text is unchanged.

  • Local OCR threads are one budget: detection and the lines of every scanned page in flight share one pool of as many threads as the processors (MARKITAI_OCR_THREADS sets another count), instead of a detection pool plus up to seven reading threads per page on top of it, and the last lines of a page are read by all the threads instead of by the one thread a late page had left. Recognized text is unchanged.

  • MARKITAI_OCR_TRACE=1 prints local OCR timings to stderr (portable engine): per scanned page the render, detection, direction, recognition and layout times, each model's optimized operators with the matrix kernels tract chose, and the operators that took longest, to find where OCR spends its time on a given CPU.

  • Local OCR has an experimental second engine, qppocr (pure Rust, AVX2/FMA and NEON kernels chosen at run time), selected with MARKITAI_OCR_ENGINE=qppocr; tract stays the default (tract and auto choose it). In the whole pipeline qppocr was no faster than tract on x86_64 laptops (a tie on a 4-core Linux laptop; 4–8 % faster on 8 scanned pages and 5 % slower on 40 on a 12-core Windows one) and about 45 % slower on arm64, so it is not the default. It reads the default and the Chinese, Japanese and Latin-script languages; tract still reads the Korean pass over uncertain lines and the other single-script languages and takes over, with one notice on standard error per process, when qppocr cannot start or fails on an image. Its own line-direction classifier (ppocr-lcnet-textline-ori, 1.0 MB) is installed only when qppocr is selected. The text matches tract's except 0 to 5 characters in about 31,000 and some blank lines between paragraphs. MARKITAI_OCR_TRACE names the engine and, for qppocr, its per-stage times. The release CLI grows by 0.3 MB (arm64) to 0.4 MB (x86-64).

  • markitai doctor names the local OCR engine that will run (tract, or qppocr, tract fallback when selected) in its OCR check.

  • Every line the CLI prints follows one terminal style, the one Python markitai 1.3.0 uses, so both look the same. A mark says what a line is (✓ success, ! warning, ✗ error, → the next step or an action in progress, • a fact, ○ skipped on purpose, ? a prompt, ◆ the title) and its colour only repeats that: green only for success, yellow only for warnings, red only for errors, cyan for the title mark, prompts and commands to copy, gray for details, bold uncoloured section headers; no magenta, blue or bright colours, so light and dark themes read the same. doctor now shows a title, the version and configuration, Optional Capabilities, LLM and Authentication sections, the remedies with each command on its own line, and a closing verdict (a green ✓ Ready. Not available (optional): … when only optional checks are missing, as Python markitai prints it, ✗ Not ready: fix … first when the configuration needs one). Stderr diagnostics read ! … and ✗ … instead of Warning: and Error: (the file log keeps those labels for searching), the batch summary starts with its verdict and ends with the output directory, init, -I, config edit, cache, auth, usage errors and --help use the same marks and colours, and serve prints each address to open on a line of its own so it copies whole. On a terminal long lines wrap under their text; piped output keeps one line per message. NO_COLOR turns colour off (the marks stay), FORCE_COLOR turns it on for a pipe, TERM=dumb gets neither colour nor wrapping, and output that cannot draw the marks (a Unix locale with another character set than UTF-8, a Windows console that cannot read escape sequences: before Windows 10 version 1511 or with "Use legacy console") uses + ! x > - o ? *; Windows pipes and files stay UTF-8 but carry the Unicode marks only when the console's output code page is UTF-8 (65001), because Windows PowerShell decodes a program's output with that code page (at 437 ✓ reads as Γ£ô): other code pages get the ASCII marks, and a process without a console keeps the Unicode ones. The spinner and the status line's ellipsis follow the same choice as the marks. Chinese chosen by the locale falls back to English where the output cannot draw it (zh_CN.GBK on Unix, a legacy console without a CJK or UTF-8 code page).

  • markitai serve keeps history for seven days, as the reference service does: a job is deleted seven days after it finished, at startup and hourly, and is hidden from history, job routes and archives from that moment; running jobs and pending retries are never expired. History was kept until deleted before. Capabilities report limits.history_retention_days: 7.

  • Default models without llm.model_list move to each provider's current small model: anthropic/claude-haiku-5-5, openai/gpt-6-luna, deepseek/deepseek-flash (the ID DeepSeek's model list reports) and openrouter/google/gemini-3.5-flash-lite; Gemini keeps gemini-flash-lite-latest. claude-haiku-5-5 starts with a forced named tool: in a live check of the presentation whose tool answers Haiku 4.5 broke, its tool answer kept all 9 protected document markers in order, so the document took one request where Haiku 4.5 needed a second, JSON-schema one; a tool answer that drops a marker still falls back to JSON schema with a warning.

  • The bundled price snapshot moves to LiteLLM 1.106.0.dev2's data (pricing_snapshot litellm-1.106.0.dev2-selected-2026-10-11), re-verified against the providers' official pricing on 2026-10-11; the eight rows priced before keep their prices. It is a development pre-release, still the newest on PyPI with the claude-haiku-5-5 and claude-sonnet-5-5 rows.

  • openai/gpt-6-luna, the default OpenAI model, now has a price: $0.10 input, $0.01 cached read, $0.125 cache write and $0.50 output per million tokens, and $0.20 / $0.02 / $0.25 / $0.75 for the whole request once its prompt is over 272K tokens. Cache writes (prompt_tokens_details.cache_write_tokens) are charged at their own rate instead of as ordinary input. OpenAI's "272K" is 272,000 tokens (its K is 1,000, as on the GPT-5 page's "Maximum input tokens: 272,000"), so the long rates start at 272,001, as in Python markitai. Responses that also break the prompt down into text and image tokens are no longer rejected as an unknown billing category.

  • GPT-4.1, GPT-4.1 mini and nano responses that report prompt-cache writes are priced instead of left unknown: OpenAI charges those models nothing extra for a cache write, so written tokens cost ordinary input, as in Python markitai. Claude 4.5 models stay priced up to their 200,000-token context window: Sonnet 4.5's 1M-token beta and its above-200K rates are no longer offered on the Claude API.

  • anthropic/claude-haiku-5-5, the default Anthropic model, now has a price instead of an unknown cost: $0.10 input, $0.01 cache read, $0.125 / $0.20 for 5-minute / 1-hour cache writes and $0.50 output per million tokens, and $0.50 / $0.05 / $0.625 / $1 / $2.50 for the whole request once its prompt, cache reads and writes included, is over 100,000 tokens. Messages responses that report output_tokens_details, zero server tool requests or null cache counters are priced instead of rejected; any server tool request still leaves the cost unknown.

  • The models a user is likely to configure now have a price, each row checked against the provider's own pricing page (cited per row in licenses/pricing/provenance.json), so costs are known for at least the models Python markitai 1.3.0 prices: every Claude model the API serves (Fable 5.1 and 5, Opus 5.5, 5, 4.8, 4.7, 4.6 and 4.5, Sonnet 5.5, 5 and 4.6) and OpenAI's gpt-6-astra, gpt-6.1-sol, gpt-6-sol and GPT-5.6 Sol, Terra and Luna (with the gpt-5.6 alias), all with Batch rates; Gemini 3.5 and 3.1 Flash-Lite, 3.8 and 3.6 Flash and the gemini-flash-lite-latest and gemini-flash-latest aliases, whose thinking tokens, missing from the OpenAI-compatible endpoint's completion_tokens, are billed as output and whose Flash introductory prices stop being applied at the end of 2026; and DeepSeek Flash (also as deepseek-v4-flash) and V4 Pro, at the peak or off-peak rate by when the request ran in Beijing time. A weekday peak hour after 2026 stays unpriced until the 2027 Chinese public holidays are reviewed, as does a request that spans two periods (request time falls outside a reviewed price period). OpenRouter requests cost what OpenRouter reports charging (usage.cost) for any model; bring-your-own-key requests stay unpriced.

  • Local OCR of an SVG reads it rendered at twice its own width (at most 2048 pixels), as the reference does, instead of the 2048-pixel vision preview: the corpus's 426-byte SVG takes 0.29 s and 162 MB instead of 1.8 s and 445 MB, with the same text.

  • Local OCR with the portable engine is faster than the reference on every benchmark input: text detection, its depthwise convolutions included, uses several threads, lines are cut out, direction-classified and read side by side, the classifier and recognizer are prepared while the first image is detected, and a PDF's scanned pages are recognized several at a time. A 12-page scan takes 5.8 s instead of 12.2 s (Python 1.2: 8.3 s), a 3-page scan 2.3 s instead of 4.4 s (Python: 4.8 s) and 15 scans at -j 4 8.8 s instead of 16.4 s (Python: 15.0 s), with identical text and less memory (the 12-page scan peaks at 1.4 GB, Python at 2.6 GB). The release CLI grows by about 95 KB.

  • Local OCR with the portable engine reads a long scanned PDF faster: up to four pages are recognized at once (a quarter of the processor count, at least two; Vision and the experimental qppocr engine stay at two), and a finished page frees its place at once instead of waiting for the oldest one. An invented 490-page scanned book (Simplified Chinese with English, 170 dpi greyscale) takes 155 s instead of 200 s on a shared 17-core Linux arm64 machine (Python 1.3.0: 500 s; through markitai serve 158 s against Python's 488 s), with identical text (99.98% of characters right; Python 99.47%). On Linux the CLI keeps glibc to four malloc arenas (unless MALLOC_ARENA_MAX is set), so the freed buffers of OCR's threads no longer stay reserved: that book peaks at 1.0 GB instead of 1.5 GB (Python 1.5 GB), the same in the service.

  • Converting a document with pictures (PPTX, DOCX, ODT, EPUB, ...) is faster: its embedded images are decoded, resized and compressed on up to four threads instead of one after another, the resize itself (Lanczos3, the bytes of before) runs on several threads without the 70 MB intermediate the image crate's version builds, and a document's assets are written on up to four threads so their ordering flushes overlap. Measured here (aarch64 Linux, 17 cores, small sample): a PPTX with one 5.5-megapixel picture 105 ms to 62 ms, a 30-slide PPTX with 30 pictures 92 ms to 44 ms, a DOCX with 40 small pictures 57 ms to 33 ms; the output is byte-identical.

  • Large XLSX worksheets use far less memory: rows are read as the XML parser closes each one instead of from a tree of the whole sheet, and parsed XML keeps no spare capacity. A 20,000-row workbook (9 MB of sheet XML) peaks at 84 MB instead of 384 MB, with the same output.

  • Omit PDF page and slide-number comments from final Markdown by default; output.page_markers and --page-markers keep them. Internal page alignment, RAG page provenance and literal code examples are preserved.

  • Write line breaks (<br>, manual breaks in Word/ODT/RTF/EPUB) as Markdown two-space hard breaks instead of a trailing backslash; normal output keeps them, including inside HTML list items.

  • The batch status line shows the time taken so far instead of a remaining-time estimate, which swung with item size and model requests.

  • The one-time notice before images leave the machine names every remote model of the routing group that may receive them, not only the first one selected.

  • The workbench is redesigned and is now the one frontend of both markitai backends (Python markitai 1.3.0 serves the same build through a written API contract; scripts/package.mjs packages it). A sidebar holds Convert, Conversions and the Settings pages (Models, Appearance and language, Access token) and collapses to an icon rail with tooltips, remembered per browser; phones get a bottom tab bar. Conversions lists every saved and running file (status, name with its extension kept, duration), grouped by submission and filtered by All, Failed or Notices; selecting one opens an inspector with its actions, facts (source, output, models, cost, kept until), its notices grouped by kind and the start of the result. Preview docks beside the list (↑/↓ move between items, Esc closes, F gives it the full width) with a fixed height, a front-matter card, the conversion's notices, highlighted code and tables capped at 500 rows with a sticky header and Show all rows; a 20,000-row workbook opens in about 0.1 s without blocking the page. Compare shows the original (PDF pages, image, the HTML a web page was converted from, text or page screenshots) beside the Markdown and outlines a hovered heading or table on both sides. On phones the inspector is a bottom sheet and Preview and Compare are full-screen views. Options includes the CLI command line, Upload offers files or a folder, and only the reader's language is downloaded.

  • In the workbench's Conversions list a submission's items are indented under its header, so a submission of one item stands at the outer edge instead of looking like part of the group above.

  • The workbench's buttons, tabs, chips and segmented controls use the interface font (monospace stays for numbers, durations, sizes, paths and code), and its button groups are real segmented groups: the All / Failed / Notices filter, the composer's Options, Upload and Upload menu, and the option segments share one outline with only the outer corners rounded and one hairline between segments, instead of separate rounded buttons with doubled borders.

  • The workbench's empty and blocked states (no conversions, no models, a filter without matches, a link to a conversion that no longer exists, conversions that cannot be loaded for lack of a token, a server or because of an error, an unknown address) are one framed panel with an icon, a title, one line and at most one primary and one secondary action; a link to a deleted or expired conversion shows that panel in the inspector's place instead of a page notice, and the Convert page shows it in place of Recent when the conversions cannot be loaded.

  • The workbench's Add models opens a provider picker with every provider this build routes (95, from the common ones to Bedrock, Vertex AI, the Copilot, Claude and ChatGPT subscriptions, inference hosts, gateways and local servers) instead of 24 cards: one search over names, model prefixes and other names; saved connections as rows with their model count, Edit and Delete; OpenAI, Anthropic, Gemini, DeepSeek, OpenRouter, Azure OpenAI, Ollama and Mistral AI as cards; every other provider in a compact list under an All · Clouds · Inference · Gateways · Local · Subscriptions · Model makers filter. Providers show their official monochrome mark (59 marks from the MIT-licensed lobehub icons, loaded only with the models settings, in the text colour) or a monogram. A subscription connects without key or address and lists its models through the installed runtime, and stays offered after its models are added; Bedrock and Vertex AI take model IDs by hand. The settings API's provider cards gain group, icon and aliases; a server without them (Python markitai 1.3.0) still gets the common providers by name.

  • Workbench model settings: a saved provider's key and address are managed separately at the top of its own page (Replace or Remove the key; Edit or Reset the address, asking for the key to use with a new address) instead of a form with a remove-key checkbox under the provider list; the one key field says it replaces the saved key, its hint is a step below the value it will hold, and Save with Cancel sits on the field's line. Edit on a model row opens the provider's page (its connection and catalogue), and Test, Edit and Delete are icon buttons. The model picker has no 50-model cap; models this group already routes at the connection are ticked, locked and listed first, Select all replaces the earlier wording, and the action reads Save (with the selection count only while something is selected) because the same page adds and edits. The dialog's actions stay pinned at the bottom, the two provider sections share one heading level, and a catalogue shorter than its scroll area carries a single rule before the manual-entry fold.

  • The subscription cost notice now follows the subscription runtime that actually served the document instead of every document in a pool that merely contains one; the unpriced-request warning no longer repeats it for subscription-only usage.

  • Remove personal machine paths from repository documentation and provenance; add checks to prevent committing private paths and common secret formats.

  • Minimum Rust version is now 1.92. Updated compatible dependencies while retaining the existing Node API level.

  • Reduce repeated PDF/HTML parsing, cache font and layout work, share compiled helpers, and reduce binary/static-library size. macOS defers optional framework loading; Python loads optional modules on demand.

  • Reduce redundant output synchronization while retaining durable publication and recovery ordering. Output files follow the process umask; private state remains private. Trusted directory aliases are accepted while document and metadata links remain guarded.

  • Improve narrow-terminal help, batch summaries, dry-run previews and actionable errors. Keep exit-code reference material in the documentation rather than the help footer. Configuration validation identifies explicitly set options that have no runtime effect; machine-readable results retain stable fields.

  • Cache CI dependencies, avoid duplicate Windows CLI builds, and show package-stage progress and timeout diagnostics. Simplify user and Agent guides; retain development history in Git instead of duplicate status documents.

  • Document binding API/version differences, platform capability limits and OCR transcription limits. Readable OCR output does not guarantee exact punctuation in code or configuration; Rosetta Vision OCR requires the native arm64 build.

  • Workbench polish after end-to-end use. On phones every page keeps the same side gutter (Settings had none) and the conversion's actions no longer run off the screen ("Enhance with L…"); a long output name stays on one line and gives up its middle (2025年度…披露全文.pdf.llm.md), shown whole on hover or a long press. Access token is one column: the status line with Forget, one field with Save attached and one short hint ("Paste a token to replace it. Kept in this tab only."). A provider's page shows the API base as an optional field beside the key, its default as the placeholder, and "Model ID and routing" (add a model by its ID, routing group, weight) as a labelled section, instead of three disclosure triangles. While a result loads, Rendered, Source, Files and Compare show its shape in grey lines in the same frame (with the size of a large result) instead of a bare "Loading…". Field labels and hints use the interface font instead of monospace.

  • The workbench opens a conversion in one window over the Conversions list, with Details, Preview and Compare as tabs, previous and next with its position, Download (or the fix: Retry, Add a model, Enable OCR) and "⋯" for the rest, instead of an inspector docked beside the list and a separate preview; on phones it fills the screen in place of the bottom sheet and the full-screen preview. In the list the arrows move and a click, Enter or Space opens; Summary opens the overview of everything listed. The Conversions composer row gives way to New conversion (dropping files anywhere still converts; a folder or several files wait on Convert). Controls share one smaller scale (28 px buttons with 13 px labels, 36 px on phones, one filled button per area, the rest in "⋯") and one segmented design for every choice; Upload ▾ became Upload and Folder; phones get Copy and Export PDF; short text actions answer taps across 44 px on phones; a settings page's primary action (Load models, then Save) sits in its pinned bar; and on a wide window every page is one centred column.

  • A folder's .env and markitai.json can no longer turn a conversion against you. The working directory's .env is still read for keys and options, but no longer for names that choose programs, paths, endpoints, proxies, home folders or credential files (*_CLI_PATH, *_PATH, *_EXECUTABLE, *_API_BASE, *_BASE_URL, HTTP(S)_PROXY, AWS_*_FILE, GOOGLE_APPLICATION_CREDENTIALS, LD_*, HOME and the like); one warning lists what was ignored. A ./markitai.json found in the folder (not named with -c or MARKITAI_CONFIG) never sends a key from the environment to an address of its own: a model or connection whose api_base is not its provider's documented address uses only a key written in the entry, and an env: key reference there becomes the markitai-no-key placeholder (with a warning). markitai doctor and --verbose say when the configuration was found in the folder. Set such values in your environment or ~/.markitai/.env, or name the file with -c.

  • Dependency majors: reqwest 0.13 (HTTPS stays rustls on ring, trusting the built-in Mozilla roots and the system store or SSL_CERT_FILE/SSL_CERT_DIR beside them, as before); hayro 0.8 for PDF page images on Windows and Linux (an embedded picture is scaled a little more smoothly; pages draw as fast as before or faster); the vendored PDF readers move to lopdf 0.46 and pdf-inspector 1.25.2 with Markitai's changes carried over (a symbol-font minus beside an exponent's digit no longer stops the run from being a superscript); the HTML stack moves to scraper 0.29, html5ever 0.40 and cssparser 0.38 with the same output; the Python binding builds with PyO3 0.30; YAML (front matter, config list --format yaml, output profiles) is read and written by yaml_serde 0.10, the maintained successor of the deprecated serde_yaml, with the same text; the workbench is built with Preact 11 and TypeScript 7 and looks and works as before.

  • Anthropic requests use API version 2026-10-21 (anthropic-version header): conversions, the connection test, model discovery and Message Batches through anthropic/ models (gateways, proxies and Microsoft Foundry in api_base included), and Claude on Vertex AI (its anthropic_version body field, previously vertex-2023-10-16). Bedrock goes through Converse, which carries no Anthropic version, so Claude models before 4.7 keep working there. temperature, top_p and top_k in litellm_params are not configuration settings and have never been sent (nor by Python markitai); since that API version refuses them on every model, an anthropic/ or Vertex AI Claude model that sets them now gets one warning: Warning: temperature, top_k not sent to anthropic/claude-sonnet-4-6: Anthropic API 2026-10-21 rejects temperature, top_p and top_k. A saved Message Batch input holding one of them, a document in an assistant message or cited text in a user message is refused before the batch is created.

Removed ​

  • markitai doctor --timings and doctor --suggest-extras (there are no extras to install; doctor names what is missing), MARKITAI_PDF_WORKERS (PDFs are read in process), MARKITAI_STATIC_HTTP (one static HTTP client) and inline terminal images (image.stdout_fetch_external has no effect; pictures on stdout are kept as files). batch.heavy_task_limit is accepted with a warning that it has no effect; use -j/batch.concurrency.
  • Microsoft Office as a page renderer (PowerPoint through COM on Windows, through AppleScript on macOS): only an installed LibreOffice draws Office slide and page images; office.macos_fallback is accepted with a warning that it has no effect.
  • ChatGPT sign-in through LiteLLM's device-code login (~/.config/litellm/chatgpt/): chatgpt/<model> uses the official Codex CLI (markitai auth chatgpt login).
  • The Python package's dependencies: its extras (ocr, serve, mcp, claude-agent and the rest) remain as names that add nothing.

Fixed ​

  • -f json with --profile rag lists the document's pages again (pages, and the page of images and tables): they were empty because the RAG profile writes its own <!-- page: N --> markers, which the JSON did not read.

  • markitai serve: a bare {"operation":"enhance"} retry of an item converted without the LLM (llm: false, preset minimal) turns the LLM on, as in Python markitai, instead of answering 409 llm_unavailable; an enhance whose own options say "llm": false is refused saying that those options turn it off.

  • markitai serve: a settings request refused for an env: value or for sending a saved key to another address or provider now names both ways out (enter the key itself, or set the pair up / change api_base in the configuration file), as Python markitai does.

  • markitai serve: PUT /api/convert-defaults with a body over 64 KiB answers 413 request_too_large (it was 422), and a kept .ipynb original is sent as application/x-ipynb+json (it was application/octet-stream), as in Python markitai.

  • --dry-run with the LLM on (--llm or an LLM preset) and no usable model warns "LLM enabled but no models configured", as Python markitai does, and still lists the targets; the real run stops there.

  • --screenshot-only with a model reads prompts.dir/screenshot_extract_system.md and screenshot_extract_user.md (Python markitai's names) before the document_vision_* templates; such files were ignored.

  • The OKF profile writes generated.at quoted (at: '2026-10-22T10:49:28Z'), as Python markitai does, so YAML 1.1 readers keep it as text instead of a timestamp.

  • A fetched page that is a bot check of DataDome, PerimeterX, Incapsula, AWS WAF or Fastly (PyPI's "Client Challenge") is no longer taken as the page: the markup only a whole-page check carries makes a short page a challenge, so auto goes on to the local browser and -s static names the challenge, as Python markitai does; a page of at most 60 words that asks for the check anywhere in its text (Press & Hold to confirm, 请输入验证码以继续访问) is one too, while a login form or an article that only mentions 验证码 stays a page.

  • A link in an X page keeps no https://t.co/… shortlink: each one left in the Markdown is replaced by where it leads, with one HEAD request to t.co whose redirect is read and not followed (at most 20 links a page, 4 at a time, 5 s each and 10 s in all, answers kept for the process), as Python markitai does; a link that cannot be resolved stays as it was.

  • The page markitai serve keeps from the local browser for Compare carries the style rules scripts inserted through the CSSOM (CSS-in-JS, as on X) and constructed style sheets, so it no longer shows unstyled, as in Python markitai.

  • A name in NO_PROXY or fetch.policy.local_only_patterns (corp.example) also covers its subdomains (api.corp.example), as curl, Go and Python markitai read it, so an intranet subdomain is neither proxied nor offered to a remote service; .corp.example still means subdomains only.

  • markitai doctor gives Chromium a minute to start and, after a timeout, half a minute more on a second try, as Python markitai does (a first launch on Windows can be that slow while antivirus scans it); a browser that starts in neither try is reported as installed but slow to start, a warning with markitai doctor to run again that fails no browser workflow and that --fix installs nothing for. Chromium refusing to run as root is now named by the doctor again.

  • A URL refused because it leads to a private or local address (a user token's URL, or a public page's redirect into this machine's network) fails with Fetch target refused: a private or local address (this fetch may reach public addresses only) on the static route and in the local browser alike, as Python markitai names it, instead of the model wording this model's address is not on the public internet or the browser's bare net::ERR_BLOCKED_BY_CLIENT.

  • A local HTML page converted into an output folder keeps its pictures, as in Python markitai: a picture it shows from its own folder (a relative path or a local file: address resolving inside that folder, not a symbolic link unless output.allow_symlinks, at most 32 MiB within the document's 100 MiB, a PNG, JPEG, GIF, WebP, BMP or TIFF by its bytes) is saved as an asset instead of keeping a reference that broke once the Markdown was written elsewhere. A refused file keeps its reference with a warning and is never read past its first bytes; links stay relative (no file:// path of the converting machine).

  • PPTX: a text box or picture placed after a slide's table (or a chart's data table) is its own block, as in Python markitai; it was written on the next line, so Markdown read it as one more table row.

  • Web pages and HTML files lose their language switchers, as in Python markitai: a list whose links carry hreflang and name languages in a few words, removed with its own controls (the "33 languages" label, "Edit links"); prose naming a few translations, reading lists of foreign-language articles and a page whose content is the language list stay. Share bars (a block named for sharing holding only a few link or button labels), short promotion boxes (named promo, newsletter, subscribe, advert, sponsor…, under 30 words) and MediaWiki's Retrieved from "…" footer go too.

  • Control characters in an HTML page's or an email's paragraphs no longer reach the Markdown raw: a vertical tab, a form feed, the file, group, record and unit separators, NEL, U+2028 and U+2029 are a space between the words (a <br> in a table cell, as before), and other control characters are left out; code blocks keep theirs.

  • EPUB: a cover named only in the package (EPUB 3 cover-image, EPUB 2 <meta name="cover">) and shown by no page is put first, as in Python markitai; prose that links to a note keeps its words followed by the note's mark (the note on sources[^3]) instead of a link to a dead #EPUB/c2.xhtml#fn1 anchor; hard-wrapped Chinese or Japanese lines join without a space, and a full-width (U+3000) indent or separator is kept.

  • Office details, as in Python markitai: an ODT list item that is a heading is a heading with its number (## 2. Methods, not 1. ## Methods); an RTF list without \levelnumbers (pandoc) shows its numbers instead of . First; pandoc's RTF lists (1.<Tab>text, •<Tab>text paragraphs) are lists, not a two-column table; and a pandoc DOCX's Author, Date and Subtitle lines are paragraphs, not level-1 headings like its title.

  • PDF: Word's Symbol and Wingdings glyphs are read by their font, as Python markitai 1.3.0 reads them: icon bullets no longer come out as Latin letters (Cv 会员可在…), a Symbol minus is −, and contents entries after Wingdings diamonds are one list item each. A large icon before a section's title no longer shifts the headings down a level, a short title in its own slightly larger face right above its paragraph (特别说明) is a heading, and a table's top rule drawn under a heading no longer underlines it (# <u>…</u>).

  • PDF: a CJK paragraph whose short last line ends in 。 no longer runs into the next one when the page sets paragraphs without spacing, and wrapped CJK lines join without a space (只是用来, not 只是 用来). On a short page of one- and two-line paragraphs, a sentence ending well short of its column ends the paragraph (a Chromium how-to's three paragraphs were one). A step numbered 2、 or 3. standing alone stays a paragraph with its number, as in Python markitai 1.3.0, instead of a - 2、 bullet; runs of numbered items are still list items.

  • Running headers and footers of output with page or slide markers (PDF, Office and presentation pages) are found as Python markitai 1.3.0 finds them: the first and last two lines of each page, up to 100 characters, repeated at that edge on 60% of the pages (four pages at least; digits ignored), are removed from those edges only, and the third line inside two removed ones goes too (a slide footer of date, text and number). The old rule looked at the last four lines, took a line on half the pages and deleted it anywhere: a slide whose body said "Acme Corp", as its footer did, lost that paragraph, and running headers stayed. Numbered body lines (OCR lines, numbered paragraphs, a long line that differs only in its numbers) and pictures are never taken for running lines.

  • A PDF whose font names neither an encoding nor a ToUnicode map (a Greek TrueType subset whose map was lost, drawing its letters with codes 1–31) no longer comes out as its commas and full stops alone (. , .) with no notice: those codes read as unreadable, so the page counts as unreadable, the conversion says to run again with --ocr (as Python markitai 1.3.0 warns that the page looks garbled), and --ocr reads it from its image (with ocr.lang el for Greek). A font entry set to null counts as absent.

  • With --screenshot or --ocr, a poster-sized PDF page is drawn at a lower resolution that fits the 32-million-pixel limit (about 28 dpi for a 200 × 200 inch page, the largest a PDF may have) instead of failing the whole file with "page exceeds the 32 million pixel limit", as Python markitai 1.3.0 does; Office documents drawn from their PDF export the same. Other pages keep 150 dpi.

  • --resume records the settings that change each item's output (--profile, --keep-base, --pure, --screenshot-only, -s, plus -f json, --text-only, output.page_markers and output.wikilinks) when they are not the default, in the recovery state and its file name: resuming with another of them converts the items again instead of skipping every item finished under the old settings; a default run keeps its state. --pure and --keep-base count with or without --llm (Python markitai 1.2.0 recorded neither, so --resume --pure kept files written with front matter).

  • --record-history copies only the pictures and screenshots of the documents it records, as Python markitai does: a file named after an output or mentioned by it. A shared -o folder no longer puts every earlier document's assets (and their images.json records) into each history job.

  • A failed item's paid model usage counts in a batch's report totals, its report entry and the closing line's cost, as in Python markitai (it was left out, so a batch with failures under-reported its spend); report entries also list each item's warnings (after error), as --json and Python markitai's reports do.

  • A resumed batch (--resume) reports the whole batch, as Python markitai does: each finished item's cost, usage, times, asset counts and warnings are kept in the recovery state, so --json lists the items earlier runs finished first (earlier_run: true) and adds totals.this_run_cost_usd, the report lists them with their own times and usage (no more null times) and adds llm_usage.this_run, and the closing line says $0.094 total, $0.067 this run. A state written before keeps their cost unknown (null, counted in llm_usage.usage_unknown_items, "cost of earlier runs unknown"), not zero. A resumed run that had nothing left to do lists every item instead of items: []. The recovery state's limits grow to 128 MiB for this.

  • markitai doctor in Chinese no longer leaves the model count and the subscription runtimes' sign-in errors in English, as Python markitai translates them; a model left out for want of a key while others still work is a warning (!), not a blocked check.

  • markitai init's update of an existing configuration adds a detected model only for a provider the list does not use yet, as Python markitai does: deepseek/deepseek-flash no longer joins beside an existing deepseek/deepseek-chat.

  • A ChatGPT (Codex) request that fails with a rate limit, an HTTP 5xx or a dropped stream is sent again with backoff (or to another model) instead of failing the whole document, as Python markitai retries it; the plan's usage limit stops the run with its own error.

  • A rate limit's Retry-After in the forms providers send (1.204s, 750ms, 6m0s, decimal seconds, an HTTP date), or a delay named in a 429/503 body (Gemini's retryDelay, "try again in 3.5s"), is now waited for instead of ignored, as in Python markitai. An exhausted request budget keeps the error of the request that spent it (… budget exhausted after a failed request: …).

  • A picture image analysis sends above 3.5 MiB (for example with image.compress: false) is sent smaller (at most 2,048 pixels, lower JPEG quality, then down to 512 pixels, with a warning if still too large) instead of being refused by the provider, as in Python markitai.

  • A model whose API key is not set is no longer sent keyless (and sent again): it is left out of the model pool before anything is sent, with one warning naming the variable to set (export NAME=… or ~/.markitai/.env); with no model left the run stops before converting, exit 1, with an error naming the variables (bindings: NoModelConfiguredError). A model with its own api_base is still tried. markitai doctor applies the same rule and names the variables (Not set: …; 1 model(s) left out, 2 still used). A refused key (HTTP 401, or Gemini's API_KEY_INVALID) names the variable it was read from and how to set a valid one, before the provider's message.

  • serve Edit config: a key written twice in one object is refused, as Python markitai refuses it. GET /api/settings/config of a file with two api_key values showed the first in clear (JSON keeps the last, so only that one was masked); it now gives no text and the problem line L, column C: the key "api_key" appears twice in one object, and PUT on /api/settings/config and /api/me/config answers 422 invalid_config with the same message. Every occurrence of a secret is masked.

  • PDF: under the default security.pdf_sanitize: warn, hidden text no longer reaches the Markdown (and an LLM after it): transparent text, text at one point or smaller, and white text over a white or near-white background are left out, as Python markitai leaves them out, and one notice quotes them (PDF hidden text: 3 span(s) on page(s) 1 left out of the Markdown (possible prompt injection; excerpt: "…")). For white text, what the page shows under each white run is sampled from a render, so white text on a dark panel stays and white text on a white box goes; remove no longer keeps every white run on a page just because something on it is painted, and also leaves out white text whose background could not be checked (warn keeps it and says so). A searchable scan's OCR layer is untouched.

  • PDF: text in a font that names neither an encoding nor a ToUnicode map, such as a CJK font subset whose ToUnicode map was lost, is no longer dropped without a word. Its title and paragraphs vanished while a few characters read as Latin punctuation (¡¢£) were kept, and the page asked for no OCR; such text now counts as unreadable, so the page needs OCR, which reads it (--ocr), and a page with nothing else readable is reported instead of written as mojibake.

  • EPUB footnotes and endnotes are Markdown footnotes numbered through the book (text.[^1] and [^1]: …): they were written as raw <a id=…> anchors and links to #EPUB/text/ch001.xhtml#fn1, with their back-links. Prose that links to a note keeps its words.

  • HTML: a code block whose language and Copy button sit in a bar below it (nodejs.org) is a fenced block in that language, without a stray JavaScript paragraph after it.

  • HTML: bold or italic text around more than one paragraph (<b> around <p>s, or one left open across a paragraph's end) is written around each paragraph, as Markdown cannot carry it across a blank line: the output held literal **Bold *start and next para***.

  • A backslash in a link or image address of a document or web page is kept (a\_b was read as a_b); a social post's video link whose address holds a parenthesis no longer ends early; and a PDF link annotation's parentheses are written %28/%29, like the document converters write them, instead of backslash-escaped.

  • Presentations (ODP): a slide's table is written once, without the TablePreview1.svm picture LibreOffice stores beside it; speaker notes no longer start with a stray <number> (the notes page's slide-number field); and a bullet whose only content is a sub-list is nested under the bullet before it instead of written as * * x.

  • Local OCR names every model it downloads, with its size, each time one is downloaded: a later download (the Korean model, say) was not announced after the first.

  • markitai doctor and URL fetches run as root say that Chromium does not start as root with its sandbox on and to run as a regular user, instead of a startup check that failed for no stated reason.

  • Code examples on pages that mark names in a code line as hover buttons (Twoslash, as on nodejs.org) keep those names: const { createServer } = require('node:http'); was written as const { } = ('node:http');. Buttons outside the code lines, such as Copy, are still left out.

  • Pictures that follow an unclosed backtick in a document (a lone ` in a Word paragraph, for example) are linked to their saved files again. The rest of the document was read as code, so those links kept the temporary .markitai/assets/asset-N name of a file that was never written, on stdout, in -o output (also with --profile) and in markitai serve results, and image analysis left the pictures out.

  • A scanned page read from its image seldom gets a sentence the page repeats written once more: each page image of a visual request now follows a Page N: label, as Python markitai sends it (Gemini Flash on pages repeating one sentence six times in each of six paragraphs: 38, 42 and 42 copies for 36 before; with the labels 36 in 4 of 5 answers and 40 in one, Python 36 in 2 of 2; a whole 12-page run now matches Python's word precision, 0.992 for 0.948 before). Cached visual answers from earlier builds are not reused.

  • Workbench on Python markitai: the pricing and subscription guide links open their pages on the Python docs site (https://markitai.dev/guide/…) instead of its home page; a docs address ending in index.md still opens the page beside it.

  • A workbook converted with --screenshot no longer fails as a whole when a sheet's text runs far past its right edge, or when LibreOffice's page rendering fails: the sheet keeps its layout (or the document its text) and one notice says what is missing.

  • A text PDF whose rows are divided by | (the native reader) now gives a real Markdown table, and the lines around it stay paragraphs of their own, where they were run into one paragraph; a line that runs past the page's right edge no longer sends its page to the plain reader that cannot do this.

  • Hidden and very hidden worksheets (XLSX, XLSM, XLSB, XLS and templates) are converted in workbook order, as Python markitai converts them, under a heading marked (hidden sheet), instead of being left out with a warning; hidden ODS sheets get the same heading.

  • Workbench: after a notice's Show items, the "N items" filter is a thin bar across the top of the list with a filter mark and × (on a phone, Select no longer drops to a row of its own), and it counts the items still there after a delete. Back goes one level at a time: an item opened from the summary or a submission's window by a notice's name, or the list narrowed by Show items, returns to that window instead of the bare list (Show items no longer leaves the page on Back); Escape and ✕ still close every level. A row's ⚠ notice mark sits level with its name (its count stands on the name's line instead of riding ~3 px high). Contents entries keep to one line with an ellipsis (formulas still drawn) and show the whole heading on hover. A submission's header row is no longer indented: its box shares the column of single items' boxes, its chevron is centred in their ✓ column and in the row's middle, and its label starts where names start. The sidebar footer and the phone Settings list show Build: YYYYMMDD hh:mm (the build's local date and time; the label is Build: in every language) as small muted text centred under Docs and GitHub, not as a row of the list. A click anywhere on the preview's properties line opens the properties table (not only on Summary ⌄), and anywhere on its header folds it; folded, the line starts with Properties · N before the tags.

  • markitai serve no longer makes its first OCR job wait for the local OCR engine to load: with the portable models installed it loads them at start, with the detector prepared for A4 and Letter pages (uncached, Linux arm64, the first one-page scan submitted 3 s after start: 0.84 s → 0.57 s, the second 0.55 s either way; about 150 MB stays in memory; nothing loads or downloads without installed models). In a batch, a free OCR detection turn goes to the earliest page of any document, so a one-page scan beside a long one waits for the detections running instead of the long scan's later pages (cjk-scanned.pdf beside an 8-page scan, uncached, median of five: 1.72 s → 1.21 s).

  • A scanned PDF page with page screenshots no longer writes its picture twice (![Image on page 1](…assets…) and <!-- ![Page 1](…screenshots…) -->): the screenshot stays and the scan's picture, and its asset, are left out; without screenshots the scan stays the page's picture. A page's screenshot comment now opens its page, right after its <!-- Page number: N --> marker, for pages of text and scanned pages alike, in the .md and so in the .llm.md (it stood at the top of a scanned page's text but at the end of a text page's).

  • A scanned PDF page's picture no longer brings the notice that embedded images are placed after their page's text (in the workbench: "turn on Page Screenshots to keep each page's appearance"): the picture is the page itself, whether OCR read it, no text was recovered or its text is the scan's OCR layer, so the notice was neither true nor actionable there. Pictures among a page's text still bring it.

  • Local OCR writes a scanned page as Markdown instead of bare lines, which a viewer ran into one paragraph: a row that stops short of the text's right edge ends its paragraph, a short row much taller than the page's text is a heading (with its wrapped second row), rows of cells in columns or divided by the rules read as | are a Markdown table (a wrapped cell joins its cell, number cells lose stray spaces: 1. 20 reads 1.20), and letter-spaced or full-width Latin is closed up (M a r k i t a i reads Markitai). The corpus's cjk-scanned.pdf now reads like the text PDF it was printed from, in the CLI and the workbench alike.

  • The terminal shows notes that only explain how the output was produced (a model answer used on its second try, pictures placed after their page's text) only with -v; reports, --json and the document JSON keep them as info notices.

  • A model answer that dropped the page marker opening a one-page PDF, or the screenshot comment closing it (Gemini Flash in tool-call mode on cjk-text.pdf and rtl-text.pdf), gets the marker back where only one place fits, as Python markitai does, instead of being rejected and paid for twice; a structured answer that is resent is reported once its outcome is known, as an info note when a later answer was used and without nested parentheses.

  • Gemini and DeepSeek requests without a configured max_tokens ask for the model's reviewed output limit within its context (Gemini Flash: 65,536), as Python markitai does, instead of the provider's default that truncated a ten-page visual batch of large-sample-x15.pdf; visual batches of text-dense pages take fewer pages so their answer fits, and a truncation names its output token count and litellm_params.max_tokens.

  • The Office page-screenshot notice no longer names the built-in PDF page renderer and gives this system's LibreOffice install command (winget, brew or the distribution's package manager); so does the --screenshot-only error for an Office file without LibreOffice, which gave the macOS command on every system.

  • A Word .doc whose picture store declares a stale count no longer warns: its pictures are recovered from complete records, and a picture that cannot be read keeps its own warning.

  • EMF and WMF pictures (a Word chart) are kept as files without an image-analysis request or a "Cannot decode" warning.

  • A real Gemini Flash run on scanned-sample-p1-3.pdf (one request, no notices) puts every page's text under its own <!-- Page number: N --> marker, where the first live run had each page's text one marker too high.

  • Workbench: the embedded-images, Word picture-record and hidden-worksheet notices are worded in Chinese and Japanese too, and the embedded-images notice says "turn on Page Screenshots" instead of naming the CLI flag and is left out for a document already told it is scanned; on a phone the header's New conversion and "⋯" buttons are one compact 32 px size, and the Edit config JSON problem takes its own row above the buttons instead of a three-line column.

  • markitai doctor in Chinese now words every check message and remedy in Chinese (the JSON values stay English), and the macOS browser line says Installed browser app found (version check only).

  • markitai doctor's closing line no longer lists Local OCR as not available while its models only need the download the first OCR does: it reads "Set up on first use: Local OCR", apart from the optional features that really are unavailable, and the check carries the info mark instead of a warning.

  • A scanned page the model transcribed no longer shows its full-page scan picture under the text: the picture stays in the file as a comment (like the page screenshots); when the model returns no text the scan stays visible.

  • Gemini Flash (gemini-flash-latest, gemini-3.6-flash, gemini-3.8-flash) now answers the document request in a forced function call, as Python markitai's LiteLLM does; a scanned page's transcription withheld as a recitation is requested once more in the next answer mode; the instruction for scanned pages asks to supplement the missing content from the page image in the original wording, and the general rules are no longer in the prompt twice; and the scan pictures of those pages are not analysed as pictures (a transcription hides them), which saves one to three image requests per scan.

  • On macOS, Vision's default language policy reads a page no single language read (a scan mixing Chinese, Japanese, Korean and Latin text, or text one recognizer gives up on) once more with all of them asked for and the language detected by Vision, instead of keeping English garbage; MARKITAI_OCR_TRACE=1 prints what each Vision reading found or why it failed. Vision cannot be run in a macOS virtual machine, so the tests for this (vision_reads_a_degraded_scan_of_chinese_with_a_table_by_default, vision_reads_the_corpus_scanned_cjk_page) run on a physical Mac only.

  • The workbench's tooltips and help bubbles read left-aligned; a submission header's box and chevron line up with its items' boxes and status marks and sit in the middle of the row; the sidebar shows the version beside Docs and the commit beside GitHub instead of a line of its own; the properties card and a body that opens with a rule no longer draw two lines; a notice's names are buttons that open that item (the first three, then "+N more"), always listed, and Show items narrows the Conversions list to exactly the notice's items.

  • The workbench's version line stays on one line (the phone header no longer repeats it; Settings ends with it), a repeated notice's count sits on the line of its files, the phone Conversions header has tighter buttons and more room under the summary, Edit config's JSON check shares a row with Discard and Review, the Cloudflare setup help is the row's info tooltip, Properties is a table with one key width and a rule between key and value, and Back (or the trackpad swipe) closes an open conversion window like Esc instead of leaving the page.

  • LLM requests, model connection checks, model discovery and the browser download now follow the same proxy decision as fetching (an HTTP(S) proxy from the environment, else the manual macOS, Windows or desktop system proxy, re-read at most every 30 seconds so a running markitai serve follows the toggle; NO_PROXY and loopback stay direct), as Python's aiohttp does. A SOCKS environment proxy is refused for them by name instead of silently bypassed. A failure to reach the proxy names it (host, port and where it is set, never credentials) instead of blaming the provider; a TLS handshake the other end cuts off says a proxy or network problem is likely, and a proxy that wants credentials or does not open the tunnel says so.

  • A failed network request names an operating-system cause by its kind and code (connection refused (os error 111)) instead of the system's own text, which Windows words in the user's language, so the message reads the same on every machine.

  • markitai doctor no longer starts a browser that lives inside a macOS .app bundle (it only asks it for --version), so macOS stops reporting "Terminal was prevented from modifying apps on your Mac" after a doctor run; the title no longer has a blank line under it, and three check messages read as plain statements ("macOS Vision API is available", PaddleOCR model files "are installed and match their SHA-256", "Old .doc and .ppt files are read by built-in readers; nothing to install"). A conversion that needs the browser still starts it as before.

  • An Anthropic response that reports usage.speed: "standard" is priced like any other (it was left without a price, as No reviewed price for claude-haiku-5-5); a fast response stays unpriced. The "Cost is incomplete" notice now says why a request has no price (unknown model, endpoint that is not the provider's own, different response model, unsupported billing category) and what to change.

  • LLM output no longer runs a page marker or a page-screenshot comment into the text beside it (<!-- Page number: 1 --> + # Title on one line stopped the heading rendering; …trade-offs.<!-- ![Page 1](…) -->): when the model glues such a kept comment or picture to its neighbours, it is restored as a block with blank lines around it. The path without an LLM already wrote them on their own lines.

  • A scanned PDF converted with an LLM and page screenshots but without --ocr is transcribed from its page images instead of left as pictures: the request now names the pages no reader recovered and tells the model to read them (their picture stays once after the text), and the "PDF is scanned: run again with --ocr" notice no longer appears once the answer has text. The notice that embedded images follow their page's text is not shown when page screenshots were written, and otherwise says to add --screenshot.

  • A Chinese terminal run is Chinese throughout: run-level errors (input not found, unknown preset, missing output directory, unsupported option combinations, unsupported formats, no model configured) and the converters' common reasons (a damaged file, a PDF page that needs OCR) are worded in Chinese on the terminal, where they stayed English between Chinese summary lines before. Details inside parentheses from parsers, the operating system or providers, --json, reports and the file log stay as written. On Windows with no language variable set, the display language decides (MARKITAI_LANG first).

  • Saving a provider's page that ticks one model and unticks another failed with "This saved configuration no longer exists" (404 settings_entry_missing): the page deleted first, which gave the remaining models new ids, and then added through the connection's old id. POST …/deployments/batch now also takes remove and applies additions and removals together against the revision the page read, all or nothing (batch_remove: true in the views); the page sends one request (an older backend: add, then delete by the new ids). After a weight saved in place the page reloads its connections.

  • A saved model whose ID the provider's list writes otherwise (claude-agent/sonnet while the list has claude-agent/claude-sonnet-5-5) was missing from its provider's list in the workbench: every saved model now shows ticked and marked Configured, on its listed row ("Saved as …", by the documented aliases) or as a row of its own ("Saved, not in the provider's current list").

  • The workbench showed a video without its poster when the poster was the item's own downloaded picture (an X post's video): the player now uses it through the item's asset route. Warnings that OCR found no text on some pages read as one notice with page ranges (both backends' wordings).

  • The workbench's Preview keeps a table that a line break in a cell broke (a raw break, or a Unicode line separator that ended the table and ran the rest of it together as one paragraph) and plays a video written as a linked poster ([![Video](poster)](https://…/clip.mp4): https only, nothing loaded until played, no referrer, back to its link if it cannot play). Warnings write page lists as ranges (pages 1–491), a scanned PDF says "This PDF is scanned: turn on OCR and convert again", and a model answer that was set aside (Python's page drift guard) is explained in plain words.

  • A line break inside a table cell is always written <br>: every table writer (spreadsheets, where Alt+Enter makes one, Word, OpenDocument, RTF, EPUB, HTML, PowerPoint, Numbers, CSV and TSV, reStructuredText, Org and LaTeX) joins a cell's lines with one <br> and counts a vertical tab, U+0085 and the Unicode line and paragraph separators as breaks; a raw separator in a row ended the table in Markdown renderers, which ran the rest of it together as one paragraph. Several breaks in a row give one <br>, none stands at a cell's edges, and a <br> written as text stays text.

  • The workbench's Compare links a web page's blocks in Safari and on slow networks: the frame was linked only at its load event, which waits for every picture of a page that runs no script (lazy loading needs script) and so came late or never, and Safari runs no handler the workbench adds inside such a page; the page is now read as soon as it is parsed and the pointer followed by the page's own hover state. Pictures the Markdown names by a downloaded copy pair with the page's picture between their neighbours. A page kept from the browser keeps its doctype, so its frame no longer lays it out in quirks mode (where hovering found only links), and a picture that could not load stays linked through its placeholder.

  • Hovering a block in the workbench's Compare no longer makes the text jump: the outline kept layout containment of the drawn chunk. The contents mark the entry that was clicked (pictures loading below it moved the mark to the next heading), and the heading at the top of the reading area while scrolling.

  • An EPUB's metadata goes to the front matter instead of a bold **Title:** … **Authors:** … block at the top of the body: authors (every creator, a list), language, publisher, date and description, as plain text (a Calibre description's escaped HTML read as text, Calibre's 0101-01-01 "no date" left out, a midnight timestamp written as a date), with the keys and values Python markitai 1.3.0 writes; the identifier is no longer written. The enhanced output keeps them, with the model's summary as description. Front matter keys follow the reference's order (title, source, author, authors, language, publisher, date, site, published, canonical_url, description, tags, …).

  • A document's title is plain text that names it, as in Python markitai 1.3.0. A PDF takes its Info title only when that names the document (a number or id such as 20240315000127 no longer does), else the most prominent visible text of its first three pages (a two-line title joined, raised footnote marks such as ¹ and white-on-white text left out), before the first heading. Every title loses HTML tags (<sup>, <mark>), entities, footnote marks and private-use glyphs, and a candidate that is a number, a page number or a default name gives way to the next heading, then to the file name.

  • A PDF page whose content stream separates its operators with NUL bytes (white space in PDF, as iText writes a page it modified) is read instead of coming out empty with a request for OCR, which lost all its text: on a Chinese form printed by iText, its whole first page, fields, table and declarations alike.

  • CJK text in PDFs is no longer set as inline code or fenced code blocks. A FixedPitch flag that the font's widths contradict (Microsoft YaHei as iText describes it) and a width table listing only a CJK face's half-width letters, its ideographs left to /DW, no longer make a face monospaced; a face whose own program says it is monospaced stays code.

  • CJK lines in PDFs are headings only when they read as one: a sentence (ending in 。, holding one, or running on past ,), a label:value field set at about the body size, and a line of more than fifteen words (three CJK characters counting as a word) are paragraphs. Items numbered 1., 1), 1、 or (1) are list items, a CJK line stopping short of its column ends its paragraph, a row of fields set far apart is a paragraph of its own, and a CJK line reaching its column's edge mid-sentence continues into the next line however widely the page spaces its lines (clauses at 20 pt leading came out as one paragraph per line).

  • A PDF table whose header row sits between two rules drawn as one segment per column, over rows without rules (a schedule's items and amounts), is read as a table with each cell's wrapped lines joined in its row, instead of a row per line or plain lines.

  • A PDF page set in a Word-exported CJK subset font is no longer sent to OCR as garbled text: the reader preferred its own remap of the font, which read the prose as nothing and every leader dot as 措, over a correct ToUnicode CMap, because CJK punctuation counted against a reading and reading nothing did not (page 1 of a public insurance clause, its reading guide and contents). Half-width Latin glyphs of such a font are no longer taken for a code face either.

  • A number set large at the top or bottom of a PDF page (a report's 2025 across its cover in 48 pt) is kept instead of being removed as a page number; folios are small.

  • PDF text no longer holds U+FFFF where the ToUnicode CMap maps the .notdef glyph to <ffff>, as iText writes it, and a bare address in bold text no longer takes the closing **, or CJK text right after it, into its link.

  • A URL item's source and canonical_url read as IRIs (https://example.com/posts/应用/ rather than %E5%BA%94%E7%94%A8): escapes of non-ASCII characters are decoded, ASCII escapes, the host and characters an IRI may not hold stay encoded, so the value is the same address.

  • Model discovery says why a list could not be loaded, in the connection check's categories (no key, found before any request; key rejected; rate limited; unreachable; no answer in time; provider error) with the provider's own message, instead of 503 "Provider operation could not complete"; the workbench shows the category, what to do and the message. A model written by hand without a key shows in the workbench's Edit as using its provider's key variable, as conversions do.

  • On Windows, Ctrl-C or Ctrl-Break during a conversion whose external runtime (a subscription runtime, Chromium, LibreOffice) is running always exits with 130, as documented. The conversion could see its killed runtime fail and exit first with status 1 and a failure message while the interrupt was still removing the runtime's workspace; it now leaves the exit to the interrupt, as does Unix for SIGINT, SIGTERM and SIGHUP.

  • Interrupting or terminating the CLI (Ctrl-C, SIGTERM, SIGHUP, closing the Windows console) while a ChatGPT, Claude or Copilot subscription request runs no longer leaves that request's private workspace, with the system prompt, settings and document images written for the runtime, in the temporary directory: the files are removed with the runtime's process tree. A workspace that still holds files a killed runtime wrote, or one left by kill -9 or a crash, is removed by the next run that starts a runtime once its owner process (now part of its markitai-<runtime>-<pid>-… name) has ended and it is two hours old. The interrupt tests, which had left six such folders in the machine's temporary directory per run, now use a temporary directory of their own.

  • Local OCR of an image or page whose full-image pass keeps no text tries again on overlapping tiles with lower thresholds, as the reference does, so a caption a few pixels high on a large, mostly empty picture is read: the corpus's sample.jpg read nothing before. Tiles without sharp edges are skipped, so a blank page costs no retry, and the retry stays within the full-image pass's memory.

  • HTML: an X Article in its status page as X served it in 2026 (twitterArticleRichTextView) is its title, cover and whole body, with its headings, lists and code blocks, instead of only the title and one picture.

  • HTML: replies to an X post from other accounts, and the author's own replies after the first of them, follow the post under ## Comments as block quotes headed **Name @handle** · [date](link), as the reference writes them, also in a timeline labelled in another language; they were left out.

  • HTML: a GitHub issue is its opening post, then its comments under ## Comments (**author** · [date](link)), without the issue viewer's avatars, screen-reader headings, "opened on … · edited by …" lines and badges; a permalink to lines of a file in it is a code block.

  • HTML: an inline SVG chart or diagram that shows text, in a block of its own, is kept as a drawing (sanitized SVG markup in an HTML block, with the colours its lost style sheet gave it resolved) as the reference keeps it, instead of its loose axis labels.

  • HTML: arXiv's LaTeXML footnotes and thanks notes are Markdown footnotes, and a footnote mark is only its mark, instead of the mark twice and a literal "footnotemark: 2".

  • HTML: a link or meta element inside a paragraph (MediaWiki writes one before every inline template) no longer splits it, which broke sentences on Wikipedia and a link around inline code on mediawiki.org; a paragraph of only white space is no longer a line of spaces.

  • HTML: page furniture that was kept is left out: arXiv's abstract-page service column and arXivLabs tabs, a clock of several cities above a live page's title, a closing heading and list of picks on other sites, a "Was this page helpful?" poll and text hidden by a BEM hidden modifier class, an RSS icon, and a site banner with the site's name and search box.

  • HTML: a code block no longer starts with a blank line when the markup breaks the line right after <code>, and a markdown (or diff, dockerfile …) label above a block is read as its language instead of a stray line.

  • Saving an OpenAI-compatible endpoint in the service settings (provider custom with an openai/ model) failed with "provider must match the model provider"; it is saved as a custom connection whose models route to its address. The OpenAI and OpenAI-compatible endpoint cards stay available after one endpoint is saved.

  • HTML: a lazy-loaded image whose address is in a site's own data- attribute (data-image-loader) behind an inline placeholder is kept, instead of being dropped with the placeholder.

  • A PDF page that binds a large image in its resources but never draws it is read as the text page it is, without the warning that it was "read as plain text because it looked like a scan" (page 5 of the reference sample.pdf).

  • PDF justified text no longer shows two spaces between words where the producer widens each break with a TJ offset before the space glyph (the bold run of the reference sample.pdf).

  • A batch item's duration_s (and report time) is its own conversion and publication work; it no longer includes the time the item waited for its publication group to commit, which made -j 1 item times add up to several times the wall time.

  • Legacy PPT slides keep their EMF and WMF pictures (the reference sample.ppt's chart preview), decoded completely as legacy DOC pictures are, instead of omitting them as an unsupported format.

  • A frontmatter title taken from the first heading is plain text: Markdown escapes (ZZZ\_Sheet\_1 from a Numbers sheet), link addresses and emphasis markers around it are no longer kept.

  • HTML: YouTube watch pages have their own reader (title, channel, description, view and date lines, loaded comments; from the rendered page or the server's player response), so the guide links and the like, dislike, share and save buttons no longer leak into the Markdown. A saved Bilibili opus page that names no address is recognized by its .bili-opus-view markup, so its reader drops the author avatar and the page chrome.

  • HTML: a link around an image-only block and a name (a Mastodon avatar beside the display name) puts the link on the name, with the avatar before it, instead of writing a link whose text holds a blank line, which Markdown renders as literal brackets.

  • PDF images are placed where they are drawn among their page's text: before it, after the paragraph above them, or after it, instead of always after the page's text, which could split a sentence that continues on the next page. Only images that cannot be located still follow the text, with the existing warning.

  • HTML: a lazy-loading page's <noscript> image fallbacks are kept as images instead of being dropped, and a <picture> whose <img> is only an inline placeholder takes the address of its first <source> set.

  • PDF text drawn outside the visible page area (CropBox) is left out of the body with a warning giving the number of items, as a viewer never shows it; a single such line was extracted before, because only neighboring-page paragraphs were clipped.

  • A serve job snapshot's job-level options lists only the supported option keys, as each item's options already did, instead of echoing everything saved with the job: the stored remote_processing consent and CLI history metadata such as origin are no longer returned.

  • The workbench's detected models come from the same table and environment as conversions: API keys in .env files count, as they do for MODEL-less conversions, the list follows the pool's priority (Anthropic, OpenAI, Gemini, DeepSeek, OpenRouter) instead of starting with OpenAI, and MODEL is also read from .env. Workbench conversions without a llm.model_list use that same pool, so with several keys they now try Anthropic first, as CLI conversions do. A key that is only whitespace no longer adds its provider to a conversion's automatic pool.

  • MARKITAI_NO_REMOTE_FETCH is read like MARKITAI_NO_VLM_OCR and the reference: any value other than an empty one, 0, false or no (trimmed, in any case) turns remote extraction off, so 1 or Y no longer leaves Defuddle, Jina and Cloudflare enabled. MARKITAI_PURE accepts on and any case, like MARKITAI_RECORD_HISTORY.

  • -p/--preset uses a presets entry of the configuration that redefines a built-in name (minimal, standard, rich), as the workbench, its capabilities and the reference do, instead of applying the built-in definition anyway.

  • Office packages (presentations, workbook page rendering and layout repair, Office metadata) and Numbers ZIP files are read through one bounded ZIP reader, and OOXML relationship targets through one resolver, instead of six copies that had drifted. A ZIP that repeats an entry name is now rejected: the ZIP library silently kept one of the entries, so the existing duplicate checks never fired. A part whose data differs from its declared size is rejected, and Office metadata gains the 16,384-entry limit; a Word document or OpenDocument text whose own reader still converts such a package gets a warning that its comments could not be recovered. Targets resolve as ECMA-376 relative references everywhere: workbook layout repair now accepts ./ segments and part names containing :, and rejects a // network path it used to read as a part; the presentation reader rejects an empty segment instead of skipping it and never reads a target whose TargetMode is neither absent nor Internal; a % escape needs exactly two hex digits and may not encode /; and XLSX/XLSB sheet names follow a percent-encoded or ./ workbook target.

  • A Copilot subscription runtime that writes more than 1 MiB to stderr now fails the request with a resource-limit error, as the Claude and Codex runtimes already did, instead of having its stderr drained without bound until the request deadline.

  • Outlook MSG conversion matches HTML cid: images to attachments the way EML does: a percent-encoded or differently cased Content-ID now finds its one attachment, a lazy-loading data-src attribute or a link is no longer rewritten to an attachment path, and a missing or ambiguous ID stays a cid: image with a warning instead of the image being dropped. MSG header values turn control characters into spaces, and MSG attachment link labels also escape angle brackets and turn control characters into spaces. An EML or MSG attachment name ending in a backslash no longer breaks its download link.

  • Configuration files are read and written the same way by conversions, config set/config edit/init and the workbench settings. A symlinked configuration file or folder (a dotfile-managed config.json or ~/.markitai, for example) can now be saved from the workbench, as config set already allowed: the link stays and the file it names is replaced, with the bounded retry other state writes use when Windows reports the file briefly in use. Every reader refuses a FIFO, directory or other non-regular file and a file over 8 MiB with an error, so a conversion no longer waits forever on a FIFO named markitai.json, and config set no longer replaces such a file with a regular one. Directories created for a new configuration file are private.

  • Building the Node.js binding from source (npm run build in bindings/node) passes --locked to Cargo like the other documented builds, so a Cargo.lock that no longer matches the manifests stops the build instead of being silently updated.

  • SVG text whose generic serif, sans-serif or monospace family is not installed under fontdb's default name now uses the first face that covers basic Latin letters, instead of whichever face loaded first, so an icon font (FontAwesome, Weather Icons, Material Icons) is no longer the primary font the text is laid out with. A host with only icon fonts still reports that the text needs an unavailable font.

  • An LLM rate limit (HTTP 429) whose message points at the billing page — OpenAI's lower tiers suggest adding a payment method to raise the limit — is retried as a rate limit instead of stopping the document and its sibling requests as a quota or billing refusal. A 429 that carries the insufficient_quota code, and any HTTP 402, still stop at once.

  • An LLM request is no longer sent again unchanged after the provider answered it with nothing or with an answer that was rejected. A JSON-text answer without any text (Gemini returned such answers for a 3-page scan) now fails after one request instead of sending the same request until llm.max_requests_per_document ran out; an empty plain answer moves only to another deployment of the group that has not been tried, without backoff; and the next attempt after a rejected structured or visual answer names the rejection in its system prompt, so it is a different request; when the same rejection repeats, the attempts end instead of sending that request a third time (image analysis then makes its separate caption and description requests at once: 4 requests instead of 5). The error names how the empty answer ended, for example LLM returned no text (finish reason: stop).

  • An answer withheld by the provider's content filter is reported as such instead of as an invalid structured response: Gemini's OpenAI-compatible endpoint words its filters as content_filter: RECITATION (and similar), which only an exact content_filter used to match. The 3-page scan of the live benchmark is a widely copied sample PDF, and Gemini withheld its transcription as recitation of published text; the error now says LLM provider withheld the answer (finish reason: content_filter: RECITATION): it resembles existing published text, which the provider does not reproduce, and the request is not sent again.

  • Every LLM request that is sent again now produces a warning on its document, and each one counts toward llm.max_requests_per_document as before: a transport retry, a move to another deployment after an empty answer, a structured answer rejected by validation (the warning names the reason and the mode of the next request, for example LLM tool-call answer was rejected (…); the request was sent again in JSON-schema mode), a rejected visual batch and the caption/description fallback of image analysis. A presentation enhanced with Claude Haiku 4.5 used to spend a second full document request in another structured mode without saying so.

  • A failed LLM request names its cause in the CLI message, the report and --json error and the log, instead of a bare LLM request failed or LLM returned HTTP 400. A request without a response says whether the connection was refused, the host name did not resolve, the TLS handshake failed (with the TLS library's reason), the connection closed early or no response arrived within the timeout (with the limit). An HTTP refusal adds the provider's error type and message, cleaned: the deployment's key and other credential-like words are replaced, URLs lose their query, quotations longer than 40 characters are dropped and a message that repeats any stretch of the prompts or document is withheld. A response that is not JSON gives its status, content type, size and an HTML page's title, never its text. Each of these failures ends with the deployment and its host, for example LLM request failed: connection refused (deployment openai/gpt-6-luna at 127.0.0.1:4000). The model connection test in serve names a missing response's cause the same way (Model connection request failed: connection refused) and a refusal's error type or code (Model connection returned HTTP 401 (invalid_request_error/invalid_api_key)), still without the provider's message. Python 1.2 reported refused, unresolved, TLS and reset connections alike as Connection error..

  • Typed documents and image analysis with Claude Haiku 4.5 start with Anthropic's native JSON schema instead of a forced tool call. In both live checks of a presentation, Haiku's tool-call answer dropped every protected document marker and was rejected, so each document paid for a second full request in JSON-schema mode, which then succeeded.

  • A document's image captions and descriptions (--alt/--desc) are requested while its text is enhanced, and its distinct images at the same time, within llm.concurrency, instead of one after another once the enhancement had returned: a presentation with one image took three sequential requests (38.7 s) in the live benchmark. Requests still spend the document's one llm.max_requests_per_document budget, and the enhancement keeps its first request, so a cap of 1 still goes to the document text. Image prompts now read the opening of the base document instead of the enhanced one.

  • A deployment's litellm_params.reasoning_effort (none, minimal, low, medium, high, xhigh, max) controls how much a reasoning model thinks. DeepSeek thinks by default, and in the live benchmark deepseek-flash spent all 8,192 output tokens on reasoning before cleaning an HTML page, failing it as truncated; deepseek/ deployments now send thinking: disabled unless an effort is set, and a value enables thinking with that reasoning_effort. Other OpenAI-compatible deployments send the value as reasoning_effort; Anthropic accepts only none, and the subscription runtimes do not accept the setting. A response truncated because reasoning used the whole output budget now says so: LLM output was truncated by its token limit: reasoning used all 8192 output tokens; lower litellm_params.reasoning_effort or raise max_tokens.

  • Without llm.model_list, a DEEPSEEK_API_KEY now adds deepseek/deepseek-flash to the automatic pool, the id DeepSeek's model list and API reference name, instead of deepseek/deepseek-v4-flash, which DeepSeek still serves as an alias of the same model.

  • --resume keeps a directory batch's progress when only the batch concurrency (-j or batch.concurrency) changed, as the reference does; before, the saved progress was set aside and every item converted again (including paid model requests), with renamed duplicates under the default output policy. When a loaded state's saved options differ in anything else (an older state, for example), --resume now stops with exit status 1 and names each differing option with its saved and current value, also under -q, and leaves the saved progress untouched, instead of starting a fresh batch over it.

  • Saved output no longer keeps secrets that error messages already hid: the source and source_url metadata saved with a URL conversion now redact the same secret parameter names as the fetch policy, so an Azure SAS sig=, an OAuth code= or session= is written as REDACTED, and so is a token in a form-like fragment (#access_token=…). A URL with nothing secret keeps its query as written instead of being re-encoded.

  • A page read by a site reader (WeChat, Zhihu, Bilibili, Douban, Jianshu, cnblogs and the other blogs, including a saved WeChat page known only by its markers) no longer crashes the process with a stack overflow when its markup or a code block nests tens of thousands of elements deep: as on every other page, content below 256 levels is kept as plain text with a warning.

  • Normal output no longer loses text after a stray [: the repair of a link whose text a line break splits starts at the link's own bracket, so prose such as a[i or [sic followed later by a link keeps every line and paragraph in between instead of being cut down to the first line.

  • A malformed legacy Office file (.doc, .xls, .ppt) whose header claims an endless DIFAT chain no longer keeps the compound-file repair busy for minutes or hours; the walk is bounded by the file's size, and a version-4 file shorter than one sector is left alone instead of panicking a debug build.

  • HTML with a code block whose lines reach past 256 levels of nesting, or a MathML formula nested deeper than 64 levels, converts instead of failing the whole document: that block or formula is kept as plain text without its formatting, with the nesting warning (which now also names the formula limit).

  • The Hacker News and X readers validate their links like the rest of HTML: a javascript: or other unsafe-scheme story, comment, "More" or quoted-post link is no longer written into the Markdown. HTML read without a page address (local files, email bodies) keeps relative links that contain a colon after their first /, ? or # (/wiki/Help:Contents, ?t=10:30) instead of dropping them.

  • POST /api/download-tickets reads at most 8 KiB of request body and answers a larger one with 413 request_too_large, instead of inheriting the 5 GiB upload limit and buffering whatever a client sent.

  • PDF content inspection reports each problem once per page (a pattern fill, an inline image, an XObject that cannot be resolved) instead of once per operator, so a page repeating such an operator can no longer produce millions of identical warnings or an error message that lists them all. Inspection also stops at a content stream of more than a million operators, as operator filtering already did, and warns that the page was not fully inspected; a stray ID operator outside an inline image no longer reports an inline image.

  • A PDF scan whose OCR-layer JPEG claims more than 32 Mi pixels in its own header is no longer decoded to check the layer: the size is read before the pixels and decoding is bounded, so a small file can no longer make the check allocate hundreds of MiB per page. The layer is read unchecked, with a warning that its image is past the pixel limit.

  • HTML text with a long run of unclosed TeX openers (\( or \[) converts in linear time instead of hanging: each opener no longer searches the rest of the text again for its closer.

  • Gemini model discovery sends the API key in the x-goog-api-key header instead of a ?key= query parameter, where request logs and proxies along the way could record it.

  • The workbench's connection editor (GET /api/settings/llm/providers/{id}/credentials) no longer returns a saved API key or the credentials in a saved address. It returns the key masked, as the reference does, in api_key_masked and api_key (sk…7890: the first two and last four characters of a key of 12 or more, the last two of a shorter one), and the address with its userinfo and secret query or fragment parameters masked (https://…@host/v1?token=…). An update without api_key keeps the saved key, an address sent back as shown keeps the saved address, and any other masked value is refused with 422 instead of replacing the secret with its mask.

  • An address the workbench does not have opens its "Page not found" page (with status 404) instead of the service's JSON error; a link to a conversion that was deleted or expired says so instead of quietly showing all conversions; and conversions that cannot be loaded (no token, no server, a server error) say why, with Enter token, Try now or Retry, instead of "Loading…" under "Nothing converted yet."

  • Workbench notices are announced by screen readers when they appear (their live regions are always in the page) and keep their list structure; a confirmation no longer disappears while the pointer or keyboard focus is on it; a failed submission says "Could not start the conversion" without a second Dismiss button; the unreachable-service notice offers Try now; on phones a notice's action sits under its text; a filter without matches offers Clear filter; and Download all is gone from an empty list.

  • A failed action on one conversion in the workbench (Retry, Delete, Download, Enhance, Copy, and a submission's Retry failed, Stop, ZIP and Delete) is one line at the top of that conversion's inspector, phone sheet or preview, in the style of its own failure reason (red ✕, one sentence, Full details), until the action is tried again or another conversion is chosen. It was a page notice, out of view when the list was scrolled and hidden behind the open phone sheet, so a failed Retry looked like nothing happened.

  • Workbench notices are minimal strips at the bottom (full width above the phone's tab bar or an open sheet's action bar; a 380 px stack at the bottom right on wide screens): one sentence with a ✕, ! or ✓ mark and at most one text action, square, on the surface colour with hairlines, at most three (newest at the bottom), leaving after 4 s (errors 6 s) except while pressed, hovered or focused, and dismissed by a swipe down; they replace the rounded boxes with a coloured bar and a close button. Lasting conditions are no longer page-wide banners: "no model" moved into the inspector of each conversion that ran without one (with Configure), a history that could not be saved into its conversions' inspectors, and no token, no server or a load error into the list's panel.

  • A link in a converted document whose address the sanitizer removed (javascript:) is shown as text instead of becoming a download link to the document itself.

  • In the workbench's Rendered view a table header cell without text is a plain cell in the header row (it keeps the header's rule and sticky place), so screen readers no longer meet nameless column headers.

  • A Chromium that Playwright installed for Linux on arm64 or for macOS on Intel is found (chrome-linux-arm64, chrome-headless-shell-linux-arm64, chrome-mac-x64), as the reference finds it, so browser fetching, screenshots and the workbench's browser capability work there without MARKITAI_BROWSER_EXECUTABLE.

  • fetch.policy.local_only_patterns and the inherited NO_PROXY entries are read by the proxy exceptions' parser instead of a second copy that had drifted: a Unicode name such as bücher.de now matches its punycode host, so a URL marked local-only is no longer sent to Defuddle, Jina or Cloudflare; in turn, proxy exceptions accept the IPv4 netmask form (192.168.1.0/255.255.255.0) that local-only patterns already did.

  • Remote fetch services and document-image downloads share one private-address check, which now also covers benchmarking 198.18.0.0/15, 192.0.0.0/24, IPv6 site-local and documentation ranges, and IPv6 addresses that carry a private IPv4 address (IPv4-compatible, NAT64 64:ff9b::/96, 6to4 2002::/16); image downloads also apply the documentation ranges they skipped. An image on an IPv6 literal host such as http://[::1]/ is classified instead of failing as an unresolvable host name, so a page on a private IPv6 address can localize its own images. Defuddle, Jina and Cloudflare receive the URL as parsed and checked rather than the text as typed.

  • An empty MARKITAI_HOME (for example MARKITAI_HOME= markitai …), HOME or USERPROFILE now counts as unset instead of making the current directory the private home, where config, .env, caches, history and browser or OCR installs were scattered and every path counted as inside the managed home. A relative MARKITAI_HOME is resolved once, against the working directory when it is first used (for the CLI, the one it started in), so messages name the absolute folder and the "inside the managed home" checks compare absolute paths.

  • The persistent URL fetch cache, which can hold URL query tokens and credential-fetched pages, is private to its owner on Unix: a directory created for it is 0700 and fetch_cache.db is 0600, instead of following the umask (usually readable by every local user). A store an earlier version left readable is narrowed, with its SQLite side files, the next time it is opened for writing; a store that is a link is refused.

  • The persistent URL fetch cache no longer reports itself unavailable, and drops the page it was saving or reading, when one of its files is busy for a moment: several conversions creating it at once on a loaded machine (a batch with -j, serve with several jobs) and, on Windows, a file that is locked, open without sharing, being deleted or held by a virus scanner. Such a read, write, removal or clear is tried again as a whole for up to 5 seconds; a damaged store, a link in its place or a full disk still gives the warning at once.

  • Learned browser domains (learned_spa_domains.db) are no longer lost, and a domain's hint is no longer skipped, with "Learned browser-domain store is unavailable" when several fetches learn or look up domains at once on a loaded machine: the store waits up to 30 seconds for a lock instead of 3, creates its table under the write lock of the first recording instead of failing when another first use holds it, reads a store another process has just created as empty, and retries a passing failure for up to 5 seconds, as the fetch cache does.

  • Local-browser fetching no longer fails when the page navigates while Markitai waits on it — above all the reload an interstitial (Cloudflare's "Just a moment", a 403/429/503 script check) performs, which the fetch exists to await. Waits now happen in Markitai while it keeps reading the browser's events, instead of as a timer inside the page that the navigation destroyed.

  • In markitai serve, a slow item retry, item delete or result read no longer freezes the job's snapshot, event stream and history while it works on files: file work runs under the job's own access lock and the shared job state is held only briefly. History reads and job creation no longer hold the job registry while locking each job or flushing a new job to disk.

  • One damaged job folder under MARKITAI_HOME/serve/jobs no longer stops markitai serve from starting or makes history, item retry and item delete fail: the folder is skipped with one warning on standard error and left in place. Retrying or deleting an item of a known job no longer rescans every saved job.

  • markitai serve lists history newest first by the actual time, not by comparing timestamp text: CLI histories record local time with an offset (+08:00) and server jobs UTC (Z), so outside UTC the two interleaved out of order. The history ZIP likewise takes jobs oldest first, so its (2) folder suffixes follow creation order.

  • A large batch no longer finishes every conversion and then fails its final recovery-state compaction (exit 1, no report). The state base is now compact JSON with a 64 MiB limit, the same as its journal, so the 100,000-entry limit is reachable when items carry LLM usage; a batch whose finished state could still not fit stops before any conversion with Recovery state projected base bytes limit exceeded.

  • An LLM request whose successful response was cut off while being read is no longer sent again: the provider completed and billed it. A request that timed out after it was sent (an HTTP API deployment that gave no response, or whose successful response stopped arriving) is sent again once, never more, within num_retries, as Python markitai resends a timeout; because the provider may have billed the first one without reporting its usage, its usage row counts it in incomplete_request_observations and the new timed_out_requests, the cost is reported as incomplete (Cost is incomplete: 1 request to … timed out.), a positive llm.max_cost_per_document_usd admits no further request, and the warning LLM request to … timed out (…) and was sent again once; the provider may have billed the first request, so the cost is incomplete accompanies the document. A response naming a missing, unavailable or regionally blocked model is no longer retried with backoff; like an authentication refusal, it moves at once to another deployment of the group, which skips that one for the run with the warning LLM deployment … is unavailable and is skipped for this run. Connection failures, temporary HTTP failures and rate limits still retry.

  • A LibreOffice export no longer leaves processes running on Linux and macOS: when the launcher exits, whatever it left behind in its process group is killed before the output is read and the private workspace is removed, as Windows already did.

  • XLSB workbooks convert like XLSX and XLS: a single sheet is headed by its name, each sheet's table follows its heading directly, and a hidden sheet left out of the Markdown gets the same warning instead of disappearing silently.

  • An ODT whose comments cannot be counted (for example a content.xml over the 16 MiB metadata limit) gets a warning that its comments could not be recovered, as a Word document does, instead of being reported as having no comments.

  • An SVG image embedded in a document (an HTML page, an e-book, a Word file) no longer adds the warning that an embedded image could not be decoded within native limits; the vector asset is kept unchanged as before.

  • A native panic during conversion reaches Python and Node.js as a ConversionError with the code internal_error, as it already did in Go and as the bindings guide states, instead of a Python RuntimeError or a Node.js Error without a code. A native panic while the Python configuration models (MarkitaiConfig and its sections) normalize their values is reported the same way, a ConversionError with the code internal_error, instead of a RuntimeError.

  • Workbook page capture no longer fails because of an optional repair step: an ODS, XLS or XLSB workbook whose right-edge overflow cannot be measured keeps LibreOffice's layout with a warning, and an XLSX/XLSM workbook whose styles the font-color policy cannot parse keeps LibreOffice's own font colors instead of aborting the export.

  • --screenshot and --ocr capture Office templates (.dotx, .potx, .xltx, .ott and the other template extensions) as the documents they make, instead of silently converting only their text.

  • Portable OCR no longer loses the text lower on a dense or noisy scan: specks and faint marks are discarded before the 1,000-region limit is applied, and an image or page that still has more text regions than that gets a warning naming it instead of silently missing its lower text.

  • Portable OCR tries an optional Korean model that cannot load (offline, say) once per process instead of for every image with uncertain lines, and loading one model no longer holds up OCR threads that read with a model already loaded. A long-running serve or MCP process uses a Korean model installed later after a restart.

  • A shared incomplete-cost warning now adds up the rows it covers instead of repeating one row's count: the card names the models that have no reviewed price and how many requests each accounts for, in the page's language, and lists the rows under its details. A batch is named for what was submitted — a folder, even one that holds a .urls list; a batch that named nothing reads as its job id rather than as one file inside it. Every ledger row keeps its action slot, so status marks, spinners and download icons line up in one column.

  • The incomplete-cost warning names the model and the reason (Cost is incomplete: 2 requests to deepseek-chat have no reviewed price…, or reported no usage counts when the provider sent no token counts) instead of saying "some observed requests"; up to three models are named and the rest are counted. One warning shared by several rows now reports as a single card that counts the rows it speaks for and lists them under its details, rather than a card per row.

  • The preset row marks a preset only while the request still equals it: enabling LLM enhancement on the minimal preset leaves all three unmarked and shows the Custom chip, instead of keeping Minimal selected beside the overridden CLI line.

  • A configured model can be unticked in the model picker: its row and badge say Will be removed, the footer names the pending change, and Save removes that routing entry (additions and removals are applied in one pass). The provider page keeps the connection table to two rows: an open key or address editor is an inset panel, and the manual-entry and advanced folds share one line below the catalogue.

  • The appearance menu stays open while a choice is pressed in a browser that focuses no button on click (Safari, Firefox), so switching the language or the theme works there instead of closing the menu before the click lands.

  • The composer's CLI line names a preset only while the request still equals it; enabling LLM enhancement on the minimal preset now reads markitai <files-or-urls> -o output/ --llm instead of claiming --preset minimal --llm. The appearance menu opens above the notice column, so a live notice no longer swallows clicks on its language buttons.

  • LLM enhancement no longer emits protected content twice: a visual-enhancement answer that re-transcribes a protected block (for example code visible in a page screenshot) is rejected and retried, and the prompt states that markers already stand for that content.

  • Read local-browser pages whose text contains an unpaired UTF-16 surrogate (a script-truncated emoji, as on Bilibili) instead of failing with Invalid Chromium protocol response. Bilibili's captcha page, served to clients that do not run its scripts, is recognized as a verification page, so auto renders the page with the local browser and a failure names the site.

  • Read Douban book, movie and music reviews as the review with a one-line header (reviewer, work, rating and time), without the avatar, the work's card, votes, comments and menus; short reviews are no longer converted with the whole page.

  • Choose a named article over site menus, link lists and comment threads beside it; leave out the page banner header, Bulma is-hidden and Bootstrap d-none elements, Wikipedia's notices to editors, multi-part tables of contents with their labels, and blocks named as furniture after the article body (an article-footer, #comments). A lede or lead picture written twice for different screen sizes is written once.

  • Preserve clearly bounded PDF bar charts as images at their text position instead of flattening axes and legends into prose; retain text when rendering or placement cannot be verified.

  • Preserve legacy DOC underlining from direct formatting and inherited character styles (link labels stay plain links) without applying it to unrelated repeated text.

  • Recover supported legacy DOC floating pictures at their body-text anchors, including separately stored image data, without importing unrelated images.

  • Require serve tokens for local and remote API access, enforce mutation Origin checks, and bind stored LLM credentials to their configured endpoints. Reject client-supplied environment references; use authenticated resource loading and single-use download tickets in the workbench.

  • Bound Office/EPUB metadata memory use, handle binding serialization failures without panics, reject non-UTF-8 physical output directories before publication, and convert Python configuration panics to ordinary exceptions.

  • Restore interrupted serve jobs as visible failures, retain completed outputs, prevent concurrent history writers and clean expired private upload stages. Retries retain prior successful results and their options, while reporting new failures and costs separately.

  • Model connection tests and model discovery read keys from .env and ~/.markitai/.env like conversion does, instead of failing with HTTP 401 when the key is only in a dotenv file. The workbench connection view reports such a key as from its environment variable instead of showing Not set.

  • Workbench: a model cost that is not fully priced reads "unknown" (or "at least $x" for partial coverage), never $0; notices sit in the page instead of floating over its buttons; repeated warnings are counted by kind instead of listed per row; the phone list keeps names readable; the conversions list is an ARIA tree without controls inside rows (no axe serious findings); Inter's Latin-extended, Cyrillic, Greek and Vietnamese subsets are included as in the reference. Correct stopped-item status, saved retry options, stale live updates, unavailable-model guidance, keyboard focus and project links; printing excludes authenticated link targets.

  • Improve PDF column and right-to-left reading order, document-wide heading levels, lists, code, links, annotations, forms, tagged/ruled/borderless tables and cross-page continuation. Preserve valid page-edge data and warn when only part of a document is readable.

  • Read supported searchable-scan OCR layers, non-embedded CJK fonts, indirect form resources and owner-password-only PDFs correctly. Password-required PDFs still fail explicitly; password form values are omitted.

  • Preserve Office/OpenDocument/RTF text, headings, lists, tab-aligned tables, monospaced code, symbols, fields and supported embedded content. Keep presentation slide boundaries, blank slides, tables and supported chart data; exclude hidden/deleted content without dropping ordinary text.

  • Correct spreadsheet headers, spans, formulas without cached values, hyperlinks, notes, number/date/duration formats and supported picture placement. Improve XLSX/XLSM screenshot overflow and default font colours without changing original documents; tolerate transient LibreOffice output files.

  • Improve HTML article/site extraction, tables, footnotes, callouts, math, code, lazy images and saved-page links; preserve streamed and declarative Shadow DOM content. Remove navigation and unrelated rails without losing short article content.

  • Extract embedded X Articles with their actual title and document structure, excluding duplicate layouts, author controls and replies from the surrounding timeline.

  • Keep Markdown literal text, fenced code, hard line breaks, footnote boundaries and safely quoted YAML metadata. Preserve EPUB code languages, notebook outputs, email body/attachment associations and original binary downloads.

  • Correct non-UTF-8 HTML, text, CSV and email decoding; sniff CSV/TSV delimiters without losing quoted fields, decimal commas or columns wider than the header.

  • Improve local OCR for small CJK text, rotation, columns, table numbers and code indentation; explain unsupported languages and recognition failures without substituting garbage text.

  • Treat login, verification and JSON refusal pages as fetch failures instead of successful documents, preserving service warnings and cache controls. Refresh cached extraction after upgrades; improve network/encoding errors and authenticated PDF handling without bypassing site refusals.

  • Keep model fallback working after a deployment rejects authentication, classify billing/region errors, respect declared input windows and retain observed failed-attempt usage. Correct eligible Anthropic pricing, trim repetitive model tails and avoid caching those answers.

  • Fix recovery option matching, output conflicts, asset relocation and concurrent cache/asset publication. Preserve prior bytes on skips, validate content before asset reuse and stop queued MCP work when its client disconnects.

  • Correct Windows path aliases, locks, atomic renames, LibreOffice discovery and external process cleanup. CLI ZIPs use a static CRT and check runtime DLL imports; Linux static Go consumers use a non-executable stack.

  • Handle closed stdout pipes quietly, honour the last paired flag, report interrupted and skipped work accurately, and return failure when a noninteractive cache-clear confirmation cannot be read.

  • When no model answers for a file (every request refused, failed, filtered or answered with nothing usable), conversion writes only the base .md, never a .llm.md, and the warnings end the failure (LLM enhancement failed: …, or Image analysis failed: … for a standalone image) with No model succeeded for this document; only the base Markdown was kept., which the workbench shows as one notice. A cached enhancement or any one answer (a cleaned document whose image caption failed) still writes .llm.md. markitai serve items list models only when their retained result is enhanced; paid failed requests still count in cost and diagnostics, and saved rows that named models for a base-only result load without them.

  • An image caption that only declines the request (I'm sorry, I can't …, I cannot …, 抱歉…) is a failed analysis, not alt text: the image keeps its original alt text with a warning, a standalone image keeps only its base Markdown, and a serve enhance keeps the previous result.

  • On Windows, markitai serve records an item's output and extracted images with / separators, so its result lists them, the Files tab and the preview find them, and deleting the item removes them; a \ in those names never resolved.

  • A connection saved from the serve settings page without a key but with an address now stores the placeholder key markitai-no-key, as Python markitai 1.3.0 does, so neither backend reads it as "use the server's key variable" for that address (Rust never sent the environment key there: web-saved entries already turned environment credentials off); the placeholder shows as no key, conversions and tests send it as written and discovery sends none. Such a keyless OpenAI-compatible endpoint, or any address that checks no key, now converts instead of being refused with llm_unavailable. A connection with a key can no longer be turned into one without a key at another host in one request (422 invalid_settings_request, which the workbench explains), and a Vertex AI connection that may not use the server's credentials is refused instead of using the server's gcloud login.

  • The serve settings API refuses a vertex_ai/ or vertex_ai_beta/ model with an address when it is saved or tested (422 invalid_settings_request with Python markitai 1.3.0's detail, api_base: Vertex AI models send the server's own Google credentials to any address, …; watsonx/ and gigachat/ the same way, naming their token) instead of saving it and failing at conversion: Vertex AI signs with the server's Google login whatever key an entry carries, so only its own endpoint may get it. Bedrock keeps its address and sends the placeholder key there, never the server's AWS credentials (checked against a capture server with canary AWS variables on draft test, saved test and conversion). The workbench no longer offers these providers an address (Add models and their saved connection say which server login they use at their own endpoint instead; Bedrock keeps its optional address) and translates the refusal in Chinese and Japanese.

  • On a phone, the window that opens right after Convert no longer hides Stop: while items wait, Stop remaining stays a button in its header (a submission, an item's submission, or everything listed from Summary) instead of folding into "⋯" while the window covers the list's Stop.

  • On a phone, the Conversions header keeps its title and status readable while Stop remaining shows: the title, New conversion and "⋯" share the first row (the buttons go below the title when they do not fit), the status sentence the next, and Stop remaining a row of its own, instead of squeezing the title to "Co" and the status to one word per line.

  • A conversion window shortens a long name in the middle, as file names already were: a web address keeps its host and the end of its path ("http://127.0.0.1:41819/sl…/s5.html"), a file its end and extension; the whole name is in the tooltip and shows on a long press. On a phone the name has a row of its own under back, previous, next and the actions, instead of being cut to "http://1…".

  • The conversion window's header puts the arrows, the position counter, the name and the buttons on one centre line (the counter, in the platform's monospace, sat about 2 px high), and Preview's Rendered, Source, Files and Diff keep their reading column centred in the window instead of against its left edge.

  • The workbench's Preview no longer freezes on a long document (a 2.4 MB EPUB with about 400 KB of Markdown made phones unresponsive in every tab). Rendered reads the Markdown a piece at a time, draws the first screen at once and the rest a few chunks per frame, lays out and paints only what is near the viewport, and fetches pictures as they come near; a drawn document is kept when switching to Details or Compare and back. Source is built when first opened, in blocks skipped off screen, and Diff uses Myers' algorithm with unchanged stretches folded, so a large pair with scattered edits is compared instead of refused as too large. Measured on a 460 KB Chinese document with 160 pictures on a 4×-throttled phone profile: the first content in 0.08 s instead of 0.19 s with no task over 50 ms (was 134 ms), main-thread work on opening 0.21 s instead of 0.92 s, and Compare, Details and back 0.04–0.10 s instead of 0.76–0.88 s. Export PDF waits for the whole document and every picture.

  • The conversion window's header keeps a long name to about 34 characters (shortened in the middle, whole in the tooltip), so the buttons keep their place, and Download, "⋯", full screen and close are evenly spaced; Details uses the same centred 820 px reading column as Preview. The sidebar names the Appearance page "Appearance" so it fits on one line (its page keeps the title "Appearance and language").

  • On a phone, Settings lists Docs and GitHub in the same list as the sections, without the extra gap and divider, with the version centred; the Convert composer is lighter, with bare icon tools and a square Convert arrow; a long name in Recent no longer runs into its time.

  • A failed model test says why, translated, under the model and at the top of the Models page: no API key (naming the key variable not set on the server), key rejected, model not found, rate limited or out of quota, provider unreachable, no answer in time, or a provider error, with what to do and the provider's own message folded below, instead of a red block of LiteLLM's text. POST /api/settings/llm/test answers a failure with reason (and key_variable for no_key); Rust finds a deployment whose key variable is unset before sending anything. The page reads the same categories from the text of a server without reason.

  • Preview's own views (Rendered, Source, Diff, Files, the contents button and Base | LLM) sit at the end of the conversion window's tab row, beside Details, Preview and Compare, instead of on a second row; on a phone, where they do not fit, they become one button naming the view and version on screen, with a menu of the views and versions.

  • A long name shortened in the middle no longer leaves a gap after the "…" or cuts inside a folder name: names are cut to the width they have, a path or web address losing whole folders first ("slow/annual-reports/…/s5.html", the host and last segment kept, a query first), a file name its middle characters (the extension kept), in the list, Recent, the window title and Details.

  • A saved Vertex AI connection no longer offers "API key · Add" or reports "Models could not be loaded": Vertex AI takes no key here and publishes no model list, so the page says that models are added by ID. The workbench knows the providers without a model list (Bedrock, Vertex AI, Perplexity, Z.ai, Fireworks AI) by name, since Python markitai 1.3.0 marks every saved connection as listable; a saved Bedrock connection keeps its optional key and address, and a saved subscription offers no key or address.

  • The workbench's Compare asks again for a PDF page the server was busy drawing (429 render_busy, a few times with a growing pause) instead of showing "This page could not be drawn", and reads the pages' text layers two at a time, as the server draws them, so on a server that has not read the PDF yet the outlines of headings and tables no longer go missing.

  • A spreadsheet row whose only value sits in a cell merged across two columns or more is a paragraph wherever it stands, as in Python markitai 1.3.0: a section label inside a sheet ("Afternoon") ends the table above it, and the next row is the header of a new table, instead of both staying data rows of one table. Columns empty in every row of a table are left out; XLSX, XLS and ODS follow the same rule, and a single-sheet ODS is headed by its sheet name like the other workbooks.

  • A table in the enhanced output keeps one delimiter row: when the model repeats | --- | --- | after the first one, the shared Markdown cleanup drops the repeat (outside fenced code), which showed as a row of dashes, as Python markitai 1.3.0 does.

  • A file named like a ZIP-based document (.epub, .docx, .xlsx, .pptx, OpenDocument) that holds something else says what it holds instead of "The file appears to be damaged or truncated": gzip-compressed data (with the file name the gzip header records, such as a PostScript .ps), other compressed data and archives, PostScript, RTF, a legacy Office file, an image, or HTML/XML/JSON text, as Python markitai 1.3.0 does for EPUB and OpenDocument.

  • A video in an X post is its poster linked to the video file, [![Video](poster)](video.mp4) (GIF for an animated GIF), as Python markitai 1.3.0 writes it, instead of the poster alone with no way to the video: the largest MP4 the post's own page data names on video.twimg.com. A post drawn by the browser (screenshots, -s playwright) reads those files from X's server-rendered page of the post with one bounded request; nothing goes to FxTwitter or another third party.

  • PDF running footers go like running headers, as in Python markitai 1.3.0: the same short line (digits ignored) at the foot of two pages or more, set smaller than the body text or, at the body size, apart from it and at the same height, is removed with page numbers in the forms the page reader left (3 / 9, 第 3 页, 第3页,共9页); running headers need two pages instead of three and a majority. A short Chromium-printed how-to no longer repeats its title footer on every page, and Chinese insurance terms and an IRS publication lose their 第 N 页 共 M 页 and Publication 501 (2025) lines. A picture drawn between two numbered steps is placed after the first step instead of after the whole list.

  • A page whose browser rendering is a verification page (Bilibili opus posts, any site's bot challenge: Geetest, Turnstile, DataDome, PerimeterX and the like) no longer ends the item without text when remote fallback is allowed: as in Python markitai 1.3.0, auto goes on to defuddle.md and Jina Reader (Cloudflare when configured), also with screenshots on and without loading the page again, keeps no copy and no screenshot of the verification page, and warns <Site> showed a verification page instead of the page, so no copy or screenshot of the page was kept; its text was read by Jina Reader. Open the page itself to compare. Without consent the failure says how to allow a remote reader. serve now honours a fetch.remote_consent: always written in its configuration, as the CLI does; the default still keeps auto local. A screenshot that would show a verification page is not kept.

  • CJK text from a PDF whose text layer names radicals for ideographs uses the ideographs, as Python markitai 1.3.0 does: Chrome's "Save as PDF" writes 使⽤ and 右下⻆ (U+2F64, U+2EC6) for 使用 and 右下角, which look alike but do not search, sort or match. The Markdown and the title of every PDF path (and so what the LLM reads) fold the Kangxi radicals (U+2F00–U+2FD5) and the CJK radical supplement forms that stand for one ideograph to those ideographs, with the workbench's Compare mapping (NFKC, then 28 supplement forms NFKC leaves alone); component shapes such as ⺁ stay.

  • A verification or JavaScript notice page counts its text in characters for the 2,000-character limit, as Python markitai 1.3.0 and the workbench contract do, instead of UTF-8 bytes: a CJK check page of about 700 to 2,000 characters is now recognized (and judged challenge in its capture) rather than read as the page.

  • A serve retry that loads no page (a fetch-cache hit, or a remote reader without a browser load) keeps the URL item's kept page, its capture record, block boxes and compare screenshots, as Python markitai 1.3.0 does, instead of removing them: the fetch cache is on by default, so a cached retry dropped a good page from Compare. A retry that keeps anything still replaces all of them (workbench contract v1.14, §7 clarification).

  • rustls 0.23.46 for RUSTSEC-2026-0346 to RUSTSEC-2026-0349 (TLS secrets in debug output, early data after an ECH rejection, a certificate decompressed before authentication, a key-exchange downgrade), and rustls-webpki 0.103.16 and webpki-roots 1.0.10 for RUSTSEC-2026-0382 and RUSTSEC-2026-0381 (distrusted intermediates, among them emSign's, were accepted); compatible dependencies are updated with them.

  • zip 9.0.5 for RUSTSEC-2026-0367 to RUSTSEC-2026-0369 in zip 8.6 and 2.6 (archive extraction, which Markitai never calls); the Numbers reader's iwork 0.2.1 is vendored so it uses the same zip.

  • serve: a user token's connection can no longer stand in for one of the admin's. A user could save a connection under the id an admin's shared model links to, and that model then took the user's address, so a key written in the admin's model went to the user's host. The user's and the admin's connections are now kept apart.

  • serve: every request made for a user token's own models (its conversions, model discovery and tests) reaches public addresses only. The check against the server's machine looked at the written host, so a name resolving to loopback, the server's network or cloud metadata passed; names are now resolved when the connection is made and non-public addresses refused, without a proxy and without following redirects. Shared admin models are unchanged.

  • serve: Cloudflare processing, billed to the server's account, needs the admin token; a user token's job or retry asking for it is 403 remote_processing_forbidden.

  • The LLM answer cache (cache.db, which holds document text and model answers) is created readable by its owner only, like the fetch cache; a cache an earlier version left readable to others is narrowed on the next write, and a link in its place is refused.

  • LibreOffice exports (Office screenshots and EMF/WMF pictures) never load content a document links to: a picture linked as a file on this machine or an http(s) address is no longer drawn into the export.

  • A local image reference is copied into the output's assets only when it is a picture (an image extension or image bytes) inside the document's folder, so a document saved beside private files can no longer copy one of them; a reference that percent-decoding turns into a root, drive or network share is refused before the file system is asked.

  • An EML file with messages nested thousands of levels deep is refused with nested message depth exceeds 64 instead of aborting the process (CLI, serve, MCP and bindings) on a stack overflow: the nesting, including messages inside base64 or quoted-printable parts, is counted before the full parse, and parsed messages are freed without recursion.

  • HTML nested thousands of elements deep no longer takes time quadratic in its depth (a 220 KB page of 20,000 nested <div>s took about 13 seconds in a debug build, a few hundred KB more would take minutes): while a page is parsed, start tags beyond 512 open elements are dropped with their end tags, as Chromium attaches them at its depth limit, and their text is kept. Pages within that depth parse exactly as before.

  • Delimited text, JSON and XML no longer grow without bound for their size. A CSV whose one wide row sits over many short ones keeps the short rows short (Markdown fills them) instead of padding every row to the widest, which made the output the product of the two; JSON whose indentation would pass 64 MiB and eight times the input is shown without indentation, with a warning; an XML outline whose indentation would pass the same limit is refused.

  • An email body that names many of markitai's own placeholder prefixes no longer costs one scan of the body per prefix: the unused prefix for unresolved cid: pictures is found in one pass.

  • A small PDF can no longer exhaust memory through many inflating streams: everything the PDF readers decompress for one document (object streams while it loads, then content, font and image streams) draws from one budget, 64 times the file and at least 1 GiB (MARKITAI_PDF_DECOMPRESS_MIB sets it in mebibytes), and each decoder stops at what remains. A stream past the budget reads as undecodable. A limit per stream alone bounds no document and would have refused a large object stream holding the document's metadata, which still converts.

  • fetch.playwright.extra_http_headers are sent only to the initial navigation origin, like http_credentials, instead of to every request the browser makes. A secret among them (an API token) no longer reaches a cross-origin subresource host, a favicon host or a redirect target; a same-origin subresource still receives them, and the automatic English Accept-Language for X still applies everywhere.

  • A URL fetch whose host is on the public internet now stays there: every redirect, <meta> refresh, browser navigation and linked picture is refused if it resolves to a non-public address (loopback, private ranges, link-local such as cloud metadata, unique-local), so a public page can no longer send a conversion into this machine or its network. A deliberately local URL (a loopback or LAN host requested directly) is unrestricted. Image downloads already did this; now the page fetch, its redirects and the browser do too. Such a fetch still goes through HTTPS_PROXY or the system proxy: each hop's name is resolved first and refused if any address is not public, and only then is the request handed to the proxy (an earlier build of this change fetched every public URL directly, around the proxy).

  • serve: a URL job a user token owns (and any retry) reaches public addresses only, from its first host onward, and uses no proxy — the same DNS/redirect/browser-aware policy, so a user token's URL cannot read the server's loopback API, its LAN or a cloud metadata service. The admin's own jobs keep their reach, subject to the public-host rule above.

  • Shipped binaries and the Python wheel no longer carry the build machine's paths. The packaging builds pass --remap-path-prefix (appended to the existing rustflags, so a configured linker flag is kept), so a release binary's panic and debug paths become registry/src/... and vendor/... instead of /home/<user>/.cargo/... and the checkout path (1,564 home-path strings in the CLI binary became none). The wheel's CycloneDX SBOM, which maturin generates with absolute path+file:// locators and offers no option to disable, is rewritten so its path-dependency locators are relative, on Windows too (where Cargo writes them as path+file:///C:/... URLs). The Go static package's shipped licenses.json and manifest.json carry repo-relative and registry-relative provenance paths instead of absolute ones.

  • Packaging hardening. No workspace crate can be published to crates.io by accident (publish = false); the build stamp's git calls accept only this workspace's own checkout (so an unpacked source tree inside another repository does not take that repository's commit) and run with core.fsmonitor=false so they never start that repository's file-monitor daemon; the wheel build tool (maturin) is pinned to an exact version (1.15.0) in CI and in the wheel's pyproject.toml, and the package driver refuses another.

  • Local OCR keeps a scanned page's structure: headings by size (levels judged across all pages of a scanned PDF, so a section is ## on every page), list items whose bullets the recognizer dropped (found in the pixels), bold and italic words (measured from stroke weight and slant, word by word), and tables drawn with ruled lines, light gray ones included, with each recognized line in its cell.

  • A chart or photograph on a scanned PDF page read by local OCR is cut out of the page and written as a picture where it stands, without its axis numbers and legend repeated as text, and the whole-page scan is no longer appended once text was read from the page.

  • serve Compare links Markdown blocks to their place on scanned PDF pages too: …/original/pages/{n}/text answers an OCR'd page with the recognized lines and cut-out pictures (source: "ocr", native pages source: "text").

  • Document JSON (-f json) gives the whole-document "PDF is scanned: …" notice the code pdf.page_without_text instead of other, and the notices for several PDF pages (PDF pages 3–5, 9 (4 of 120): …) their page codes (pdf.page_without_text, pdf.page) instead of other; items lists every page such a notice names, not only its first range.

  • markitai cache stats --json on a home without cache.db or fetch_cache.db reports each as an empty store (count 0, size_bytes 0, size_mb 0.0, max_size_mb and db_path; by_model {} and entries [] with -v) instead of null, so jq .cache.count gives 0; inspecting still creates neither file.

  • --profile okf front matter starts with type again and follows Python markitai's order: type, title, description, resource, tags, generated (by, then at), then the other fields; it was written title, …, generated, resource, type, which mattered to readers that check the first line.

  • image.format: webp honours image.quality, as in Python markitai 1.2, instead of writing every picture as lossless WebP (several times larger than Python markitai 1.2's output; 398 KB where 1.2 wrote 54 KB for a sample photo). This build has no lossy WebP encoder, so below quality 100 pictures and PDF/Office page screenshots without transparency are written as JPEG at that quality, named .jpg in the files, links and model requests (108 KB for that photo), with one warning per document (image.webp_as_jpeg); pictures with transparency, and everything at quality 100, stay lossless WebP.

  • A URL that redirects no longer hands its address to the next site: markitai's own HTTP requests send no Referer, so a token, key or signature in the original query is not passed to a redirect target on another host (reqwest sent the full previous URL; Python markitai sends none).

  • A secret in a URL's path, such as the token in /reset/<token>, a token=… segment or a long random-looking segment, now reads REDACTED in --json, front matter, serve item names, history, reports and MCP, as it already read [REDACTED] in the log; the log, output metadata and the fetch policy use one judgement, and a short code after a route such as /verify/ or /invite/ (/verify/123456) counts. In serve, such a URL's address is kept only by the running server, so retrying it after a restart is refused as for a URL with userinfo. The log no longer hides ordinary segments such as percent-escaped words.

  • Query and fragment parameters named pass, pw or passphrase are treated as secrets: their values are redacted in output and messages, and such a URL is not sent to remote fetch services.

  • A CSV or TSV fetched from a URL becomes a Markdown table again, as in Python markitai 1.2: text/csv, application/csv and text/tab-separated-values, reStructuredText, Org, LaTeX and notebook types, and a text/plain response whose path ends in one of those extensions (as raw-file hosts serve them) are read by the local reader for that format instead of being kept as raw text.

  • PDF superscripts, subscripts and footnote marks set off the line by reportlab (<super>, <sub>) and LibreOffice (footnote anchors) stay on their words again, as in Python markitai 1.2 (70 m², hourly¹, CO₂), instead of being dropped or moved to the start of the next line (70 m, 2 Water covers…, a line of 3 4); a footnote's raised number at the foot of the page is no longer removed as a page number.

  • A two-column PDF no longer splits a paragraph after a line of the left column that ends in a full stop when the next word could not have fitted on it: the line is measured against its own column, not against a title centred across both columns.

  • PDF text keeps a hyphen set between spaces as printed, as Python markitai 1.2 does (Monday - Friday, Harbour Gazette - Spring Issue), instead of joining the words (Monday-Friday), also where the hyphen is drawn as a run of its own, in a line or a table cell; in-word hyphens and words broken at a line end read as before. A running header repeating such a line atop each page is now left out like other running headers.

Markitai 1.x (Python) ​

Python markitai, which 2.0.0 replaced.

[1.2.0] - 2026-09-28 ​

Added ​

  • llm.on_failure: a failed LLM enhancement (invalid key, provider error, timeout, refusal) keeps the unenhanced .md and says why; set it to "fail" to fail the item instead (exit 1, or 10 for a batch).
  • Warnings you can act on, in default mode and wherever results go (stderr, --json, the web workspace, the Python API, MCP): scanned pages without --ocr, hidden PDF text, OCR or screenshot failures, slides that cannot be rendered, failed LLM or image analysis.
  • --resume for .urls lists, and Ctrl-C saves the batch state in every mode.
  • --json reports an --llm-batch handoff (a batch object with the collect command, a pending status) and per-item warnings.
  • cache.fetch_ttl_seconds (default 24 hours) expires fetched pages without ETag/Last-Modified; cache stats/cache clear cover the fetch cache.
  • markitai.enable_worker_processes() and MARKITAI_PDF_WORKERS for parallel PDF extraction; markitai.api exports ConversionError, FetchError and NoModelConfiguredError.

Changed ​

  • PDFs convert up to 4× faster, with identical output: pages go to worker processes that each run the layout model on one thread. On an 18-core Mac a 300-page PDF and a batch of 40 PDFs are 4.1× faster, a 240-file mixed batch 3.3×.
  • LLM batches overlap conversion with the model calls (30 documents with --alt: 1.5× faster), and a document longer than one request is enhanced in chunks instead of being cut at 32,000 characters.
  • The web workspace matches the CLI: up to 1000 items per job (was 50), 3.2× faster on 240 files, and the API stays responsive while a job runs. Assets are cached by the browser and responses compressed.
  • OCR reads in reading order: columns, vertical CJK, upside-down scans and tables. --ocr keeps the structure of pages that have a real text layer; RapidOCR >=3.9 is required.
  • New outputs follow the umask (usually 0644) instead of 0600, and an overwritten file keeps its permissions; config files and .env stay private.
  • -c/--config-json apply to subcommands, and config set/config edit can create a new -c file.
  • The Python API and the MCP server resolve models like the CLI and no longer fetch LiteLLM's cost map over the network; markitai-mcp loads .env like markitai mcp.
  • Extracted images and screenshots are named after the resolved output (report.pdf.v2-0001-01.jpg), so a re-run written as report.pdf.v2.md no longer overwrites the images report.pdf.md references.
  • Web extraction follows defuddle 0.19.4: code fences, <sub>/<sup>, labeled dates and shadow DOM content.

Fixed ​

  • Batches and resume: a batch no longer converts its own output or .markitai/ assets, finds .Docx-style extensions, and keeps same-named files and URLs apart. A resumed item overwrites its own earlier output (no .v2 copies), even when resumed from another directory.
  • LLM output: the cleanup prompt names every page, slide and image placeholder, so models such as Gemini Flash-Lite keep page boundaries instead of having their cleanup discarded. A refusal is never accepted or cached, failed image analysis keeps the original alt text, and a failed run leaves this run's .md and no stale .llm.md.
  • --llm-batch: cached image answers are kept, --resume no longer resubmits an uncollected batch (it prints the collect command), --alt/--desc are applied at collection, Anthropic image requests work, and credentials come from llm.model_list.
  • Web fetching: pages decode with their declared charset (GBK, Shift_JIS, CP1252) and survive a stray invalid byte, relative links resolve after redirects, a challenge page never replaces a good cached copy, and loopback and NO_PROXY hosts bypass the proxy.
  • Screenshots: taken before the DOM is cleaned, with every tile kept and their own time budget; a page that hangs after load times out.
  • PDF and OCR: images are written under paths with spaces or brackets, OCR failures fail the item instead of reaching the output, and multi-frame TIFFs read every frame.
  • Web workspace: Ctrl-C shuts down promptly and keeps the job in history; a failed Enhance, or a shutdown during a re-run, restores the previous result; concurrent deletes and names that differ only by a suffix no longer clash.
  • Also: .urls files with a BOM, quoted multi-line TSV cells, .msg emails with an HTML-only body, read-only outputs, an output directory that cannot be created (one error line instead of a traceback), and MCP tools now return the failure reason.

[1.1.0] - 2026-09-14 ​

  • About 6× faster overall: URLs 5.8×, Office/PDF 5.6×, batch conversion 6.6×, measured in equally weighted local benchmarks that leave out LLM, OCR and internet latency.
  • Faster results: less startup and repeated parsing, and no browser fallback for short articles and CJK prose that arrived complete the first time.
  • Output quality unchanged: text, metadata, equations and images go through the same handling as before, and complex documents still take the established fallbacks.
  • Public URL detection fixed: Defuddle and the other remote readers now work behind Fake-IP proxies such as Clash, and the private-address protections still apply.
  • Better X/Twitter extraction: when remote use is allowed, anonymous posts are fetched without launching a browser; an HTTP error falls back promptly, and rich text, links, images and publication dates survive.
  • Domain tuning kept: a custom domain profile overrides only the fields it sets explicitly, so the built-in browser settings stay in place.
  • Quieter repeat runs: the remote-fetch and VLM OCR notices show once per user; consent prompts and opt-outs work as before.

[1.0.1] - 2026-09-10 ​

Added ​

  • --json conversion results with per-item status, output, usage, cost and run-level errors; requires -o and excludes --dry-run / --llm-batch-collect.
  • --no-remote-fetch to disable remote URL extraction and --log-level to control configured file logging.
  • MCP output profiles and bounded batch concurrency (default 10); omitted llm now follows server configuration.
  • Bilingual website comparison pages, search/social metadata, and documentation and UI checks in CI.

Changed ​

  • Serve sign-in URLs use #token=; config list/get/set redact secrets, including nested request headers, unless --show-secrets is explicit.
  • Web workspace adds upload progress/cancel, row downloads, clearer options and errors, improved accessibility, and settings/preview loading on demand. Removes Tailwind and unused UI code.
  • CLI progress goes to stderr; help and errors clarify output paths, supported formats, optional dependencies and removed flags.

Fixed ​

  • Prevent same-name MCP outputs from overwriting each other and stale LLM files from appearing in API results.
  • Preserve job options across retries/restarts, prevent premature completion and restored ghost rows, and isolate concurrent ZIP downloads.
  • Validate and pin public destinations through redirects and browser subrequests for anonymous remote jobs; isolate their fetch caches and browser sessions.
  • Limit Batch enhancement to the current run and report final artifacts, usage and failures correctly. Preserve RAG/Obsidian images, wikilinks and alt text through Batch processing and history.
  • Handle malformed configuration, cache replacement limits, empty URL lists and error exit codes consistently; align release checks with fragment-based sign-in URLs.

[1.0.0] - 2026-09-09 ​

Added ​

  • markitai is now a library as well as a CLI: markitai.convert("report.pdf") and its async twin aconvert return a typed ConversionOutput holding the markdown, frontmatter, asset and screenshot paths, per-image analysis and usage totals, and they reuse the CLI's own configuration layering. Provisional: signatures and result fields may still change
  • markitai mcp starts the bundled MCP server from the CLI, the form the official MCP Registry entry (io.github.Ynewtime/markitai) uses: uvx --from "markitai[mcp]" markitai mcp
  • MCP agents get markitai through the mcp extra: markitai.mcp ships in the main wheel and exposes convert_document, convert_url, batch_convert and job_status over stdio. claude mcp add markitai -- uvx --from "markitai[mcp]" markitai-mcp
  • --profile rag|obsidian|okf shapes the output for its consumer: rag moves images into a visible assets/ (hidden paths are skipped by ingestors like LlamaIndex's SimpleDirectoryReader) and rewrites PDF page markers; obsidian adds optional wikilinks; okf maps frontmatter to the Open Knowledge Format. Orthogonal to --preset; without it the output is byte-identical
  • --llm-batch runs a directory's LLM stage at half price through the provider's Batch API on a single-model OpenAI or Anthropic pool, waiting up to --llm-batch-timeout (1h) before handing off to --llm-batch-collect
  • --llm-batch covers image analysis and page screenshots: --alt/--desc and --screenshot ride the same job at the same discount. A document with more pages than fit one call is enhanced live instead, since each extra batch round is a separate wait
  • The web UI can choose an output profile, beside Preset, and deliberately not hidden with the LLM switches: a profile shapes output rather than enhancement, so a plain local conversion can carry one
  • Three circuit breakers bounding one document's cost: llm.max_requests_per_document (default 50) caps retry multiplication; llm.max_vision_pages_per_document is checked before anything is sent, so an oversized document costs nothing; llm.max_cost_per_document_usd is charged after each answer, bounding what a document goes on to spend. A trip skips the rest of that document's enhancement and keeps the unenhanced output. The two cost caps default to off
  • Legacy .doc/.ppt conversion through markitai[legacy]: the bundled-Rust anydoc backend handles Office 97-2003 in milliseconds, with no Microsoft Office or LibreOffice on any platform
  • --ocr --llm is now named, measured and gated: that combination sends page images to a vision model rather than running RapidOCR, which nothing used to say. Help, doctor and both CLI guides now call it VLM-OCR, metadata records which path ran, and MARKITAI_NO_VLM_OCR=1 forbids it outright
  • Long page screenshots are tiled instead of shrunk: a page taller than screenshot.tile_height (2000px) splits into full-width tiles rather than being downscaled into one unreadable strip
  • Mathematics comes back as LaTeX: the prompts ask for $...$ / $$...$$ at every stage that sees a formula, where pymupdf hands display equations over as images and mangles inline math into markdown noise
  • CLI conversions can opt into the web UI history with --record-history (or MARKITAI_RECORD_HISTORY, or history.record), recording a completed run as a job under ~/.markitai/serve/jobs/
  • Conversion quality is now measured, three ways: a snapshot guardrail freezes default output for a PDF/DOCX/PPTX/XLSX fixture set, an opt-in A/B harness compares prompt and model changes, and the webextract parity corpus scores HTML extraction against defuddle
  • The serve API contract is machine-checked: every JSON route declares a pydantic response model, scripts/export_openapi.py exports the schema with SSE payloads injected, and a test holds webapp/src/api/types.ts to it
  • The defuddle port has a manifest and an upstream watch: PORT_MANIFEST.md records which upstream sources each webextract module tracks and the commit the parity corpus pins, with a test keeping the two equal
  • The website serves /llms.txt, and the README carries a comparison table against markitdown, docling and anydoc that states what each does better
  • NOTICE records the third-party obligations MIT alone cannot cover: the AGPL-3.0 PyMuPDF stack and what it means for redistribution and network use, the defuddle port (MIT © kepano) behind webextract/, and the marker-derived benchmark scorer
  • CI fails on a non-commercial or unexpected copyleft dependency: scripts/check_licenses.py reads licence metadata from a no-extras install and rejects anything non-commercial or proprietary, plus any AGPL/GPL package outside an explicit allowlist
  • Substack article extraction reads rendered bodies and window._preloads JSON, custom domains and byline dates included; Notes extraction and the generic fallback stay
  • Linux desktop proxy discovery reads the manual HTTP proxy and bypass list from GNOME/Unity and KDE. An explicit environment proxy still wins, and PAC, SOCKS-only and authenticated desktop settings are left alone
  • Opt-in benchmark LLM scoring: score_with_llm_judge returns validated content, structure and noise scores, reuses an offline cache, caps its input and never retries on its own. A cache miss needs an explicit model and allow_network=True; the default benchmarks stay heuristic and offline
  • Eight more formats convert without an extra: .tsv, .xml, .rst, .org, .tex, .odt, .ods and .rtf now convert in the base wheel with the standard library alone. A tab-separated file is already a Markdown table, and an OpenDocument file is a zip of XML
  • .rtf has a real native reader: a tokenizer for groups, control words and \'xx bytes drives a group-scoped formatting state, so outline levels and stylesheet names become headings, \trowd/\cell/\row becomes a Markdown table, Word 97 \pntext glyphs and newer \ls/\ilvl lists keep their bullets and nesting, HYPERLINK fields become links, and \ansicpg/\fcharset decode CJK and Windows codepages. No extra, no extraction engine
  • Every boolean CLI flag has a spelled-out negation: --cache, --compress, --no-pure and --no-screenshot-only join their siblings, so a config default can be overridden in either direction from the command line

Changed ​

  • The three LLM routing surfaces merged into one MarkitaiRouter: local providers dispatch through their handler table and standard models go back to litellm's own group balancing, cooldowns and fallbacks instead of a hand-rolled reimplementation
  • Structured LLM calls use the strongest mode a model actually supports: native tool calling, then response_format JSON schema (which also switches on the native path the claude-agent provider had never used), with markdown-JSON demoted to a last resort
  • One URL pipeline behind all four entry points: serve, the Python API, markitai <url> and URL batches wrapped near-identical copies of fetch → localize → enhance → write, and now share one cascade in workflow/url.py
  • Prompt caching actually engages: five system templates had their per-request variables near the top, leaving a stable prefix too short for any provider's cache to match. The variable segments moved to the tail; instruction text is unchanged
  • Converting a .txt no longer loads a PDF and Office toolchain: converters registered eagerly, costing ~430ms per process and pulling in markitdown, Magika and onnxruntime regardless of input
  • A provider is described in one table: its default model and API-key variable had been hand-copied into four other places (credential detection, the setup wizard, serve's startup candidates and init), and the copies had drifted
  • Retirement warnings and cost estimates are derived: the replacement model now comes from the defaults table and the retirement date from litellm's own record, replacing a stale literal and one hardcoded date stamped on every model
  • Self-explanatory errors no longer leak exception class names: a missing OCR backend, an oversized file or an unsupported format prints the actionable message alone
  • The parity corpus is pinned to the same upstream as the algorithm: fixtures resynced from a March 2026 snapshot to defuddle 0.19.3 (83 → 208), after porting the six upstream behaviours the new corpus exposed as missing
  • webextract parses and copies less: one lxml path for fragments and documents, five copies of the block-tag table converged, no whole-tree deep copy per retry attempt, and one srcset implementation
  • A default install is 155 MB smaller, 633 MB down to 477 MB. opencv-python left the core entirely and RapidOCR moved behind a new ocr extra. Scanned-document and image OCR now needs uv tool install "markitai[ocr]" --force
  • Images come out better without OpenCV: Pillow alone beat it on every sample measured, with higher PSNR and SSIM and 7.7% smaller files, because OpenCV's INTER_LANCZOS4 downscale skips anti-alias prefiltering
  • Every dependency moved to its current release, litellm 1.91.1 → 1.97.0 and markitdown 0.1.6 → 0.1.7 among 56 updates, with the licence audit re-run against the upgraded tree
  • The PDF engine floor moved to pymupdf4llm>=1.28.2: 1.28.0 pulled in pymupdf-layout under a Polyform Noncommercial licence, which forbids commercial use outright; 1.28.2 restored AGPL-3.0 dual licensing
  • Setup no longer asks about LibreOffice: slide rendering is an opt-in runtime path that a local MS Office already covers on Windows and macOS, so the guided installer leaves it out; a PPTX --screenshot/--ocr conversion with no renderer available warns at conversion time and names the per-OS install command
  • --no-pure beats MARKITAI_PURE: an explicit flag on the command line now overrides the environment variable in both directions, where the env var previously won

Removed ​

  • The three-platform Office automation is gone: about 1.3k lines of Windows COM, macOS AppleScript and LibreOffice CLI driving, plus the batch pre-conversion machinery around it. markitai[legacy] covers the formats it existed for, and the LibreOffice hints now speak only about PPTX slide rendering
  • The kreuzberg extra and the -b kreuzberg backend are gone: every format it covered now converts natively, so the extra had nothing left to unlock. --backend is native|cloudflare, the fetch.kreuzberg_convert_enabled setting is dropped, and --kreuzberg is a usage error that says .rtf converts natively rather than naming a replacement
  • The six deprecated fetch and backend flag aliases are gone (--playwright, --defuddle, --static, --jina, --cloudflare, --kreuzberg) in favour of -s and -b. A removed name is now a usage error naming its replacement, and --help drops from 36 options to 30
  • Two redundant defence layers inside the LLM path: a hand-rolled JSON-mode strategy sitting below a structured ladder that already does the same repair, and a hand-maintained Copilot price table live traffic had stopped reaching
  • The llm.prompts.page_content_system and llm.prompts.page_content_user settings, with the code path they configured: both modes their docstring claimed had long since routed elsewhere, so setting either did nothing
  • FFmpeg is no longer checked, advertised, or installed: markitai has never supported audio or video, yet doctor presented FFmpeg as "audio/video file processing" and the installers offered to fetch it

Fixed ​

  • The OpenAI-family defaults refreshed to the current generation: gpt-5.6-luna replaces gpt-5.4-nano at $0.20/$1.20 against $0.20/$1.25. A stale default only fails once the model starts refusing, so a guard test now fails when any default comes within 120 days of the retirement date litellm records
  • Azure OpenAI deployments get the api_version they configure: litellm_params declared no such field and unknown keys are ignored, so the documented Azure example had it dropped on parse and never reached litellm
  • A PDF converted with --ocr arrives as pages again: both OCR paths joined pages with a bare blank line and screenshot-only emitted a marker spelling no reader matched, so an OCR'd document reached LLM batching, the drift guard and output profiles as one undivided run of text
  • The document whose LLM call failed keeps the same layout as the rest: every failure path returned before the pipeline's profile step, leaving one file in a --profile run with the default asset layout
  • --llm-batch explains itself when it refuses --ocr: page images travel inside the document's own request, and the batch converts with the LLM off first, which is the branch that reads scanned pages with local OCR rather than rendering them
  • --llm did nothing without a config file: the step that fills the model pool from MODEL or a detected key was gated on the config's own llm.enabled and ran before --preset and --llm were applied
  • --resume could not resume an interruption: the CLI batch path never wrote the base state file the loader needs, so an interrupted run left a delta sidecar nothing could replay and paid for every document again
  • --llm-batch-collect honours --config-json: it built its configuration without the inline overrides, so a collect run's live fallback used whatever pool the config file named
  • Batched requests to reasoning models stopped failing outright: OpenAI's batch deployments reject function tools while reasoning is on, so every document 400'd and fell through to a live re-run. Requests now carry reasoning_effort="none" where the model is reasoning-capable and the request uses tools
  • router_settings.fallbacks actually works: deployments were addressed directly by id, so litellm never saw a group to fall back from. A configuration naming no default group now fails at startup instead
  • Claude models no longer cost $0 in the usage report: litellm answers with zero prices for a name its table lacks, which read as "free" and hid the entry a fuzzy match would have found. Relatedly, gpt-5.4-codex was priced as plain gpt-5.4
  • Truncated LLM responses are counted in the cost report: a response cut off at the token limit was discarded before its usage was recorded, so the most expensive calls were the ones missing from the totals
  • Improving a prompt now actually changes the output: the persistent cache was keyed on a call's category rather than the prompt, so a document converted before a prompt change kept serving the old result
  • A failed LLM call can no longer leave a document permanently blank: an empty result from every retry was written to a cache with no expiry, so the document "converted" to nothing on every future run
  • A finished conversion can no longer report failure: onnxruntime's teardown at interpreter shutdown occasionally aborts the process, turning a conversion that had already written its output into a non-zero exit
  • --ocr without the OCR extra no longer reports success: it fell back to an image placeholder and exited 0, so the file "converted" with nothing read from it
  • Install hints name commands that work: extras names were eaten as rich markup, and pip install / uv add act on the current project rather than markitai's isolated environment. One helper now renders the command and reads sys.prefix, so a pipx install is told about pipx and a uv tool install about uv tool
  • The wheel now carries LICENSE and NOTICE: license-files named a path that does not exist inside the package directory, so every published wheel shipped without a licence
  • Six extraction gaps against defuddle 0.19.3: article text inside dismissible aria-hidden overlays, CodeMirror code blocks, mid-article image rows, Substack Notes, SVG figures and inline related-story blocks. The resync also fixed what it newly exposed: Hugo admonitions, lightbox duplication, LaTeX image services, <noscript> fallbacks and line-numbered code
  • Reddit, Hacker News and YouTube pages are quality-checked again: their gate was registered under a profile name the extractors never emit, so all three fell through to the generic-article check
  • Embedded PDF images survive symlinked and out-of-tree output directories: pymupdf4llm may write paths symlink-resolved or relative to the working directory, and only one spelling was being rewritten
  • CMYK JPEGs no longer fail image compression: images from print-sourced PDFs reached a path handling only RGBA, P and LA, and were dropped
  • The screenshot viewport settings finally do something: screenshot.viewport_width and viewport_height were declared, described and published in the schema, but no code read them
  • Two settings that looked configurable never were: auto_proxy and the screenshot full_page flag were read with a getattr default although no config model declares either
  • NO_PROXY is honoured on every fetch path: the unified resolver had no callers at all, and the Playwright launch, both Cloudflare paths and the batch renderer resolved a proxy without consulting the exception list, the system list on macOS and Windows included
  • Proxy auto-detection no longer invents a proxy that isn't there: it probed common local ports for a TCP connection, which succeeds for every port under a TUN-mode VPN
  • markitai --help opened on the Batch API: the --llm-batch flags belonged to no panel, and ungrouped options render first. The examples also offered a YouTube conversion that has never existed
  • The installer asks about mirrors only when the default index is actually slow, rather than warning every user without a proxy. Someone in Frankfurt used to meet a question about China mirrors as their first impression
  • A "no" to OCR is now respected by the installer: suggestions were merged back into the selection, so declining it still installed it
  • The test suite is hermetic to the developer's own configuration: a real ~/.markitai/config.json or .env used to fail parts of the suite on the machine of anyone who actually uses markitai
  • Mobile conversion uses one compact source card: the URL area sits above one shared row of Options, upload and Convert instead of three stacked controls, with 44px touch targets and a mobile CLI preview that wraps on its own. Desktop keeps the inline composer
  • The composer actions share one lightweight style: Options, file upload and Convert are borderless, evenly padded controls with the same hover and focus treatment; Convert still reads as the primary action through heavier ink and a light accent wash instead of a filled box
  • The options panel is grouped: the preset leads as the primary row, with its adjusted status and hint; Enhance collects LLM, OCR and image analysis; Output holds the profile; and the URL/file fetch selectors, source switches and cache/compression toggles fold into an Advanced section that opens on its own when one of them is already off the default. Every control, hint, linkage rule and translation is unchanged
  • Presets and conversion options stay linked across the panel, the API request and the CLI preview, all reading the preset definitions the server provides. Selecting a preset resets its five features; image analysis pauses and comes back with LLM/plain mode; screenshot source and remote backends resolve the same way everywhere. The CLI preview defaults to a short preset-plus-deviation command built from the server's preset map and states the default-config assumption it makes; when the local configuration differs, an “Include config overrides” option keeps the explicit off flags for screenshot-only, pure, cache and compression reachable

Security ​

  • markitai serve now issues an access token: the startup banner prints sign-in URLs and requests from other machines must present it, replacing the old unauthenticated partial access with a 401
  • markitai serve can no longer be steered into the network it runs on: with --host 0.0.0.0 any LAN machine could submit a URL and have the server fetch it, private addresses and cloud metadata endpoints included
  • Binding beyond loopback now says what that means: --host 0.0.0.0 warns that anyone who can reach the server may convert files and read, download or delete the entire conversion history
  • fetch.remote_consent=ask now asks before X/Twitter enrichment too: that path went remote without prompting, honouring only a decision made earlier

[0.23.0] - 2026-07-18 ​

Added ​

  • Export any web-workspace preview to PDF: the rendered Markdown prints to a clean A4 document, with real margins on all four sides, code blocks and figures that never clip mid-page, and table headers that repeat across pages. A "PDF settings" menu holds an optional custom header/footer (on by default); PDFs always print light so the result is identical in Chrome, Safari and macOS Preview

Changed ​

  • The web workspace is now usable on phones and narrow windows: a single ≤780px layout gives the composer, options and task list one coherent column. The header collapses to identity plus history/settings, language and theme move into the settings dialog, external links and notifications dock to the bottom, and the batch download sits under the task list
  • Batch download moved next to the conversion options on desktop, so it no longer competes with the Clear action in the workspace header

Fixed ​

  • The local server now refuses cross-origin and rebinding requests: a Host/Origin allow-list (loopback, IP-literal hosts, and any --allowed-host you pass) blocks a malicious web page from reaching the API, where it could otherwise read saved provider credentials or drive server-side URL fetches. Same-origin use and LAN binds keep working
  • In-place retry and LLM enhancement are reliable: retrying an item while its siblings are still converting no longer strands it as "queued"; a failed enhancement keeps the working base result instead of downgrading the row to an output-less error; an interrupted rerun no longer erases the whole job from history on restart; and re-runs overwrite their own prior outputs instead of accumulating versioned duplicates or serving a stale enhanced variant
  • Scanned-document OCR stays accurate across a batch: the sparse-page tiled fallback no longer lowers detection thresholds process-wide, so later pages are OCR'd at full confidence
  • LLM alt text applies to images whose names contain spaces or CJK: references written percent-encoded were not being matched, so captions were silently dropped for those assets
  • Quoted posts from X keep their exact text: an interior ellipsis or "show more" run inside the quote no longer fabricates a truncation marker on complete text
  • Editing only a provider's API key no longer pins its default API base: the editor field is empty (with the default shown as a placeholder) when the connection uses the provider default, so saving keeps tracking that default
  • Whole-history downloads clean up after themselves: the generated archive is removed once the download finishes and can no longer survive as an orphan, and a file deleted mid-build is skipped instead of failing the download
  • The completion notification reads "N done" for a clean job instead of "N done · 0 failed", and the local server's job finalization no longer blocks other jobs' live progress while sizing large output directories

[0.22.0] - 2026-07-14 ​

Added ​

  • markitai serve adds a local web workspace for conversion: install the serve extra to upload files or folders, submit URLs, watch live progress, preview and download results, and revisit seven days of on-disk history from a bilingual, accessible interface
  • LLM setup moves into the web workspace: it finds the local and API-backed providers you have, lists their models live, lets you configure weighted deployments and test a connection without showing the stored credential, and conversion jobs run on the same settings
  • Web jobs can be retried and compared: a failed file or URL item can be retried on its own, a large result set filtered, a completion notification arrives in the background, the equivalent CLI command is one click to copy, and the base Markdown sits beside its LLM-enhanced variant

Fixed ​

  • PDFs with spaces or parentheses in the filename now get embedded-image analysis: pymupdf4llm sanitizes the source name when writing extracted images (My Paper.pdf → My_Paper.pdf-0001-00.jpg), but embedded images were collected by globbing on the raw input name, so --alt/--desc/rich silently skipped every embedded image while page screenshots kept working. Collection now resolves the image refs written into the Markdown, keeps the previous prefix match as a fallback, and warns when a referenced asset is missing from disk
  • Web result downloads work with nested artifacts on Windows: API paths use URL separators instead of filesystem backslashes, so images and other generated assets open through the same links on every supported OS

[0.21.1] - 2026-07-12 ​

Fixed ​

  • Fresh installs on macOS and Windows no longer try to compile litellm from source: litellm 1.92.0 shipped Linux-only wheels, so uv tool install markitai on other platforms fell back to building its new Rust extension from the sdist. That failed outright without a Rust toolchain, or with a wall of pyo3 errors when the build environment picked an unsupported Python (e.g. a system-default 3.14). That release is excluded; dependency resolution lands on a version with wheels for every supported platform

[0.21.0] - 2026-07-12 ​

Changed ​

  • Legacy .xls files now convert in pure Python (xlrd via MarkItDown, same engine as .xlsx): no Office application or LibreOffice is launched or required, on any platform. Cell content is identical to the previous Excel-automation output (verified live); conversion is also faster and no longer serialized behind an app lock
  • The persistent LLM cache is now scoped to your model pool: switching model configuration stops serving results produced by the old models, while rotating an API key keeps every hit; document cleanups also keep hitting after a file is renamed (identical content, different name). Existing cache entries miss once after upgrading; the old entries were unreachable anyway (see Fixed)
  • Structured LLM calls now go through the full transport retry loop (exponential backoff, quota/billing short-circuit, empty-response retry) that plain-text calls always had; they previously bypassed it entirely. With a persistently degenerate model the worst-case attempt count is accordingly higher. That is the cost of actually retrying
  • Standalone .urls batches gain vision enhancement in the --screenshot multi-source corner, matching what a single-URL conversion of the same page produces
  • Internal: the fetch, LLM, and CLI subsystems were restructured behind unchanged behavior. Machine-enforced import-layering contracts now run in CI, fetch.py shrank from ~3000 to ~1100 lines with per-strategy modules, the LLM pipeline is a single engine instead of five hand-rolled copies, and report/exit-code/output handling is single-sourced across all input paths. A 43-item regression matrix over every 0.15–0.20 fix verified nothing regressed

Removed ​

  • Python 3.14 support: requires-python narrows to >=3.11,<3.14 across the package and installers, and the CI/publish matrices no longer test 3.14. The platform-conditional ONNX Runtime constraint that existed only to keep 3.14 resolvable is gone with it
  • Excel COM automation (Windows) and Excel AppleScript automation (macOS), together with the .xls entries in the batch pre-conversion and heavy-task paths, since .xls no longer needs them

Fixed ​

  • The cross-session LLM cache never actually hit: the persistent cache's reads and writes disagreed on the cache key's model column, so no run ever saw a previous run's results, and every rerun of an already-processed document paid the full LLM cost again. Reads and writes now agree (locked by a regression test), and reprocessing an unchanged document is near-instant and free
  • macOS .doc/.ppt conversion via Microsoft Office was broken end to end: Word's and PowerPoint's AppleScript open returns nothing, so the script's document binding was never established. The first reference died, the error handler's own reference to the same variable masked the real error behind an inscrutable "variable openedItem is not defined", the document was never closed, and the stranded zombie documents degraded the app further with every retry. The script now binds the document by its unique staged name, closes it under both its pre- and post-save names, and always propagates the original error. Excel's "Parameter error -50" had the same root and is fixed the same way (moot for .xls now, see Removed, but the fix also covers the PPTX-to-PDF export path)
  • macOS: .doc/.ppt conversion failing on an Office app's first scripted launch after an Office update ("the document never registered" / PowerPoint error -9074). In that state the app silently drops Word's parametered open request while answering everything else; Word's script now retries with a plain open mid-poll (verified to penetrate and heal the state) and logs the recovery. Error messages for the stall and for -9074 now carry the verified remedy (open the app manually once so it finishes first-run setup) instead of the misleading "app stuck or overloaded, quit and retry"
  • Truncated or degenerate LLM output no longer poisons the cache: screenshot extraction and URL enhancement now reject length-truncated responses like every other call site, and URL enhancement no longer caches output whose degenerate tail had to be cut
  • markitai -I no longer crashes with a raw traceback when a config file is invalid: the interactive wizard now reports the same actionable message as every other command
  • Directory-batch reports no longer drop screenshot counts for URL entries
  • The defuddle HTTP client now rebuilds when proxy or timeout settings change (it previously kept the first client for the whole process, like jina always did correctly)
  • Disabled models (weight: 0) no longer affect cache scoping: disabling a model stops serving its old cached results, and adding or removing a disabled entry no longer invalidates the pool's cache
  • Copilot authentication status reads correctly with current Copilot CLIs: newer CLI versions write JSONC-style comments and renamed the login key, which made doctor and auth copilot status report a parse error as "not logged in". Both formats are now accepted, an unparseable config reports as indeterminate rather than unauthenticated, and a login the CLI itself reports as successful is never vetoed by the follow-up config read

[0.20.0] - 2026-07-10 ​

Added ​

  • macOS can use installed Microsoft Office when LibreOffice is absent: AppleScript drives Word, PowerPoint or Excel to convert legacy .doc, .ppt and .xls files, and PowerPoint can export PPTX slides to PDF for rendering. office.macos_fallback switches the fallback on, headless sessions can turn it off, and doctor and the documentation explain the one-time Automation permission macOS asks for

Changed ​

  • Public URLs go remote without a prompt; private ones never do: a public URL may use the remote fallbacks without confirmation, and one process-wide stderr notice covers defuddle.md, Jina, Cloudflare, FxTwitter and Twitter oEmbed. Private, local, DNS-resolved non-global and credential-bearing URLs (sensitive path tokens included) stay local, and MARKITAI_NO_REMOTE_FETCH=1 blocks every remote path, an explicit remote -s included
  • doctor checks that a capability works rather than that a package exists: only RapidOCR and the configured workflows can fail a normal run; Playwright is verified by launching Chromium, the environment variables the active models reference are checked, and --fix, when asked for, installs and rechecks Chromium and leaves the project's dependencies alone
  • markitai init now preserves existing configuration by default: pressing Enter selects Keep, while Update and Overwrite remain explicit choices
  • Onboarding starts with the portable installer: the homepage works out Windows versus macOS or Linux before first paint and recommends setup.ps1 or setup.sh, with uv tool install markitai kept as the manual route. It also carries a 60-second no-LLM example, and the Chinese navigation, screen-reader and high-contrast support got better
  • Copilot pricing metadata recognizes gpt-5.6-luna when explicitly configured: the automatic OpenAI and ChatGPT onboarding defaults stay on generally available models, and the limited-preview model is opt-in

Fixed ​

  • Quiet mode is consistent across single and batch work: Markdown you asked for on stdout still arrives, errors stay on stderr, a quiet dry run prints no preview, a partly failed URL batch keeps its successful outputs and exits 10, and progress and success chatter stays hidden
  • Image conversions with no enabled extraction path no longer report success: a standalone image without --ocr or --llm now exits 1 with an actionable message instead of producing no output with a successful status
  • Installer reruns preserve intent: the shell and PowerShell setup scripts skip markitai init --yes when ~/.markitai/config.json already exists, preserve existing extras, and honor an explicit MARKITAI_VERSION even when Markitai is already installed
  • Homepage quick-start commands remain readable in light mode: the dark command panel now always uses light text and transparent code backgrounds and stacks before the commands get cramped, and the site serves a real /favicon.ico instead of a 404
  • Python 3.14 dependency resolution avoids an incompatible ONNX Runtime pin: the constraints are now per platform, so Magika's Windows cap stays where it is needed and other platforms can take a supported ONNX Runtime release

Security ​

  • Configuration output hides secrets by default: markitai config list recursively redacts secrets and custom header values, cuts api_base down to its origin, and shows the original values only when you pass --show-secrets
  • URL credentials stay local and out of diagnostics: terminal errors, progress labels, dry-run previews, console and file logs and generated output names all drop userinfo, sensitive path tokens, query strings and fragments, and a hostname that resolves to any non-global address never crosses a remote dispatch boundary
  • macOS Office automation isolates untrusted documents: the fallback opens a read-only staged copy with macros and external-link updates disabled, binds and closes only the exact document it opened, serializes app access across processes, and keeps the recoverable staging files private
  • A headless install does not count as consent to optional software: without a usable terminal, the portable installer puts in uv, Python and Markitai and nothing else, unless MARKITAI_INSTALL_OPTIONAL=1 explicitly turns on optional packages, browser binaries, system dependencies and third-party CLIs

[0.19.0] - 2026-07-10 ​

Changed ​

  • Remote extraction no longer prompts by default (fetch.remote_consent default ask → always): public URLs fall back to remote extraction services (defuddle.md, Jina, Cloudflare, tried one at a time and only after the local strategies fail) without an interactive confirmation; an INFO log discloses the first use. Private/local URLs never use remote services regardless of this setting, and URLs carrying credentials in the netloc (user:pass@host) are now treated as private too. Set fetch.remote_consent=ask/never or MARKITAI_NO_REMOTE_FETCH=1 to restore prompting or disable remote services
  • Consent prompt rewording (for remote_consent=ask): the prompt now explains why it appears (local extraction didn't succeed), that services are tried one at a time (first success wins), and lists only the services actually in the chain, so Cloudflare (which runs against your own account credentials) appears only when configured. Interactive prompts also pause the live progress display instead of tearing it

Added ​

  • Live progress checklist (StageList): multi-stage live progress for single-URL and single-file conversions. Completed stages persist as ✓ Fetched via fxtwitter (2.1s) lines, and the active stage shows a spinner with an elapsed-time suffix. Stdout-mode conversions (no -o) finally show progress; they were previously fully silent through fetch + LLM enhancement

Fixed ​

  • --resume was a no-op: the CLI batch entry point accepted the flag but always reprocessed every file from scratch. It now loads the saved state, skips completed files, retries failed or interrupted ones, picks up newly discovered files, and reports Resuming batch: N completed, M remaining
  • Output naming reverted to the append scheme: sample.pdf → sample.pdf.md (not sample.md), undoing the 0.15.0 extension-replacement change, which hid the source format, mangled multi-suffix names, and made single-file and batch conversions of the same file disagree
  • Windows install one-liner 404: the website now serves setup.ps1 (docs pointed to https://markitai.dev/setup.ps1 but only setup.sh was deployed); Chinese changelog edits now trigger site redeploys
  • Prompt REMINDER leaked into cleaned output: with smaller models (observed with gpt-5.4-mini), the vision-cleaning prompt's trailing REMINDER: ... instruction, --- delimiter included, could be echoed verbatim at the end of .llm.md output. The prompt now delimits the document with <document> tags and puts all instructions before the content, and a new output guard strips echoed prompt fragments, including from previously cached results
  • Image alt text was silently skipped for some URL conversions: URLs with a screenshot but no multi-source content (e.g. X posts via site extractors) fell through to text-only LLM processing without image analysis, and the URL-batch path never analyzed images at all, so --alt/--desc had no effect there. Both paths now analyze downloaded images (alt text + images.json). The stdout asset rewrite also no longer overwrites LLM-generated alt text with the bare filename
  • Batch image analysis no longer trips over bare-payload JSON: small models sometimes answer a single-image batch with the bare item instead of the {"images": [...]} wrapper; this burned Instructor retries and fell back to per-image analysis with an ERROR log. The JSON repair layer now coerces such shapes in place, so the batch succeeds directly
  • Weak models could mangle social-post bodies during LLM cleanup (flattened quoted-post blockquotes, respaced CJK text; observed with claude-agent/haiku): content profiled as social_post now passes its body through verbatim and the LLM only generates metadata. For all other document types, the document-processing prompt gained explicit blockquote-preservation and CJK-spacing rules
  • ChatGPT connection errors were non-retryable and unreadable: httpx transport failures (connection reset/refused, timeouts) were mapped to a non-retryable ProviderError with an empty message, bypassing every retry layer. They are now marked retryable and carry the underlying error text
  • Console log lines no longer tear the live progress display: log output now routes through the shared rich stderr console, so lines print above the StageList spinner instead of leaving stale frames behind; quiet/stdout mode also applies the same third-party retry-noise filter as normal mode (raw instructor retry errors previously leaked through)
  • Failed LLM enhancement is now visible in the output: when every LLM path fails, the fallback .llm.md frontmatter carries llm_enhanced: false and an ERROR-level log is emitted. Previously the only hint of degraded output was an empty description

Removed ​

  • Legacy progress facilities retired: internal ConversionStatus, ProgressReporter, and OutputManager are replaced by StageList; markitai.utils/markitai.cli no longer export ProgressReporter, and attempt_login() lost its unused output_manager parameter
  • Dead code sweep: unreachable async enricher registry, unused exception hierarchy, deprecated no-caller helpers, test-only utility functions, and ~5MB of unreferenced test fixtures; markitdown dependency narrowed from [all] to the office extras actually used (drops azure/audio/pdfminer/youtube transitive deps); httpx and lxml are now declared directly

[0.18.0] - 2026-07-09 ​

Changed ​

  • Web extraction parity with Defuddle: main-content selection and noise-pattern removal now closely port Defuddle's algorithms (scoring, content patterns, content-boundary detection). Benchmark corpus mean vs. the Defuddle ground truth: 91.04 → 92.72

[0.17.0] - 2026-07-08 ​

Removed ​

  • Gemini CLI provider (gemini-cli/): Google retired the OAuth onboarding it depended on. Use a direct GEMINI_API_KEY or route through OpenRouter instead

Added ​

  • COPILOT_GITHUB_TOKEN auth support, checked ahead of GH_TOKEN/GITHUB_TOKEN
  • Elapsed-time indicator on slow conversion stages, so a long-running LLM call doesn't look hung

Fixed ​

  • Cloudflare fetch now respects site-specific extractors: switched from the /markdown to the /content endpoint so Cloudflare-routed pages get the same extraction quality as every other strategy
  • Reduced console log noise from duplicate retry/validation messages

Changed ​

  • Local providers no longer waste tokens on unused extended-thinking/reasoning output
  • Provider detection and init now suggest cheaper default models

[0.16.0] - 2026-07-07 ​

Added ​

  • Bilibili opus extractor for bilibili.com/opus/<id> posts
  • Anti-bot/CAPTCHA detection: challenge pages (Geetest, Cloudflare, reCAPTCHA, hCaptcha) are now recognized instead of silently treated as real content

Changed ​

  • X/Twitter extraction is DOM-first again, falling back to the FxTwitter/oEmbed enricher only when native extraction comes up short

Fixed ​

  • X Article URL matching, fetch performance, and frontmatter word-count bugs

[0.15.0] - 2026-07-04 ​

A maintenance release: refreshed dependencies, Python 3.14 support, and several rounds of auditing that fixed 30+ verified bugs across batch processing, fetch/cache, LLM providers, image handling and configuration.

Added ​

  • Python 3.14 support; MIT License added to package metadata
  • Grouped, faster --help via rich-click
  • Garbled/scanned-text detection for PDFs, with an advisory suggesting --ocr
  • Repeated header/footer suppression across PDF pages
  • VLM degeneration guard (truncates repetition-loop vision/OCR output)
  • HTML extraction and footnote-handling parity with Defuddle (MathJax/MathML, code blocks, footnotes across many site types)
  • Unified -s/--strategy fetch flag (old per-backend flags kept as deprecated aliases)
  • Remote-fetch consent: URLs are no longer sent to third-party services without consent (fetch.remote_consent, MARKITAI_NO_REMOTE_FETCH)
  • PDF hidden-text sanitization (prompt-injection guard): security.pdf_sanitize
  • Per-page OCR routing for mixed digital/scanned documents
  • Conversion-quality benchmark harness against a Defuddle ground-truth corpus
  • Release automation via release-please

Fixed ​

The main fixes from several rounds of bug hunting:

  • X/Twitter DOM extractor rebuilt for X's 2026 markup redesign; FxTwitter fallback now actually reachable from the default fetch chain
  • .eml email support (native), HEIC/HEIF/AVIF image input (markitai[heif]), quality guardrails gate for CI
  • markitai init merges into an existing config instead of overwriting it; clearer login-failure guidance across providers
  • Fetch/cache correctness: stale AUTO-strategy cache revalidation, Playwright context leaks, proxy auto-detection false positives, null URLs crashing batches
  • LLM/provider correctness: failures no longer silently reported as success, vision cache poisoning, Copilot concurrent temp-file races, event-loop stalls from blocking calls, retry backoff holding concurrency slots
  • Image/conversion correctness: EXIF orientation, LA-mode transparency, uncompressed-image naming, EMF/WMF mislabeling, OCR engine config drift, temp-directory leaks
  • Config/CLI correctness: config editor validates before save, symlink safety check fixed, llm.concurrency lower bound enforced, JSON log formatting, config set type coercion and bracket-notation support

Changed ​

  • Mixing an input path with a subcommand is now an error instead of silently dropping the input
  • -o out.md on a single file/URL writes exactly that file
  • Diagnostics moved to stderr so piped stdout output stays clean
  • Output naming switched to an extension-replacement scheme (sample.pdf → sample.md); reverted in 0.19.0
  • image.stdout_persist now defaults on
  • Reports (.markitai/reports/) are batch-only by default
  • Dependency refresh (litellm, opencv-python, playwright, instructor, and others)

Security ​

  • litellm supply-chain pin lifted (>=1.83.0) now that upstream has audited and signed releases

[0.14.0] - 2026-03-25 ​

  • Added: Steam News extractor; structural MathML-to-LaTeX conversion; LibreOffice functional (not just presence) check
  • Fixed: PDF math-content extraction fallback; BBCode XSS prevention in Steam content; flaky integration tests hardened
  • Security: litellm pinned to <1.82.7 (supply-chain incident)

[0.13.1] - 2026-03-23 ​

  • Added: Config editor redesign: fuzzy search, scrollable list, in-place UI refresh
  • Added: Field descriptions added to 66 Pydantic config settings
  • Fixed: Esc-key support, bool-editor consistency, and Literal-type value preservation in the config editor

[0.12.1] - 2026-03-22 ​

  • Added: Inline terminal image display (Kitty/iTerm2) for stdout mode, backed by a content-addressed asset store
  • Added: Chinese user-journey documentation
  • Fixed: LLM errors now visible in quiet/stdout mode; Kitty protocol image format fix; init no longer generates duplicate provider entries

[0.12.0] - 2026-03-20 ​

  • Added: Native HTML extraction pipeline: resolver-based extraction, frontmatter builder, quality profiles, and structured extractors for GitHub Discussions, X threads, and YouTube
  • Added: --static and --kreuzberg CLI flags
  • Changed: HTML files now route through the native webextract pipeline by default
  • Fixed: URL stdout fallback, thread-safety for shared caches/semaphores, atomic config writes

[0.11.2] - 2026-03-14 ​

  • Fixed: Windows RAM detection for task sizing; lazy ~/.markitai/ directory creation (no side effects on read-only use); output/log dirs now default to None instead of a hardcoded path

[0.11.1] - 2026-03-14 ​

  • Added: Pure-mode option in the interactive wizard
  • Fixed: --pure no longer wrongly triggered vision/screenshot paths; lowered an over-aggressive "content too short" threshold

[0.11.0] - 2026-03-13 ​

  • Added: --pure mode: transparent LLM pass-through (text cleaning only, no frontmatter/post-processing), decoupled from --llm
  • Added: --keep-base to force writing the base .md alongside .llm.md
  • Fixed: URL processors now respect --pure/--llm/--keep-base consistently with file processing

[0.10.0] - 2026-03-12 ​

  • Added: Auto-detect LLM providers from environment variables and authenticated CLIs when no config exists
  • Changed: -v is now --verbose (was --version); -V is --version
  • Changed: Cold-startup time cut via lazy imports (~5s → ~0.3s)
  • Fixed: Alt-text language now matches the document's language instead of defaulting to English

[0.9.2] - 2026-03-11 ​

  • Fixed: Copilot/Claude login now always uses inherited stdio (fixes credential storage failures); clearer error messages instead of opaque wrapped exceptions

[0.9.1] - 2026-03-09 ​

  • Added: markitai doctor --suggest-extras as the single source of truth for install-script extras
  • Fixed: Login-guard and extras-parsing bugs in the install scripts; Rich-markup escaping fix for provider names

[0.9.0] - 2026-03-09 ​

  • Added: Configurable global/per-domain fetch strategy priority, and local_only_patterns/inherit_no_proxy to restrict sensitive domains to local-only strategies
  • Fixed: LLM output no longer translates mixed-language page content into the wrong language

[0.8.1] - 2026-03-06 ​

  • Added: Defuddle fetch strategy (free, no auth) as a new top-priority option; --defuddle CLI flag
  • Changed: Default strategy order updated to lead with Defuddle/Jina

[0.8.0] - 2026-03-06 ​

  • Added: 20+ new file formats via markitdown/kreuzberg (HTML, CSV, EPUB, MSG, IPYNB, Numbers, TSV, XML, ODS, ODT, SVG, RTF, RST, ORG, TEX, EML); GIF/BMP/TIFF image support
  • Fixed: Claude Agent SDK compatibility bump; i18n test isolation; import-time log noise from kreuzberg registration

[0.7.0] - 2026-03-05 ​

  • Added: ChatGPT provider (chatgpt/) via OAuth device-code flow
  • Added: Gemini CLI provider (gemini-cli/), later removed in 0.17.0
  • Added: weight: 0 to explicitly disable a model in routing
  • Fixed: Router division-by-zero when all models were weight-0

[0.6.1] - 2026-03-05 ​

  • Fixed: Claude Agent SDK compliance fixes; auth pre-checks now recognize more environment-variable-based credentials

[0.6.0] - 2026-03-04 ​

  • Added: Cloudflare integration: Browser Rendering for URLs plus Workers AI toMarkdown for files
  • Added: Fetch Policy Engine with domain profiles and Playwright session persistence
  • Added: Pluggable static HTTP backend (httpx/curl-cffi)
  • Fixed: Router division-by-zero when all vision models were disabled; removed 21 dead functions across the codebase

[0.5.2] - 2026-02-07 ​

  • Fixed: SQLite connection leaks; Windows path-handling bugs; stale OAuth-expiry false positives; Pyright warnings cleared

[0.5.1] - 2026-02-07 ​

  • Added: Playwright auto-scroll for lazy-loaded content; DOM noise cleanup (nav/ads/cookie banners) before extraction; python -m markitai support
  • Changed: Default models modernized across init/interactive/doctor; cache fingerprint switched to a full-content hash (was a short prefix, prone to collisions)

[0.5.0] - 2026-02-06 ​

  • Added: markitai init setup wizard and interactive mode (-I); doctor --fix auto-install
  • Changed: CLI startup ~3x faster via lazy module loading; batch UI simplified to a compact progress display
  • Fixed: Windows LibreOffice/FFmpeg detection; Playwright default wait condition (was causing hangs)

[0.4.2] - 2026-02-03 ​

  • Changed: Playwright wait defaults tuned for better SPA support
  • Fixed: X/Twitter pages now wait for full JS rendering before capture; caches respect the configured directory instead of a hardcoded path

[0.4.1] - 2026-02-02 ​

  • Added: markitai doctor diagnostic command; adaptive timeout for local providers; prompt caching for long Claude Agent system prompts

[0.4.0] - 2026-01-28 ​

  • Added: Claude Agent SDK and GitHub Copilot SDK local providers; HTTP conditional caching (ETag/Last-Modified) for URLs; --quiet/-q flag
  • Changed: Major module reorganization (cli/, llm/, providers/)

[0.3.2] - 2026-01-27 ​

  • Added: Chinese README and setup scripts

[0.3.1] - 2026-01-27 ​

  • Added: SPA domain learning: auto-detect and cache JS-heavy sites to skip wasted static-fetch attempts
  • Added: Windows performance tuning (thread pool sizing, OCR engine singleton, faster image compression)
  • Fixed: Prompt-leakage prevention (system/user prompt split); auto-proxy detection for fetching

[0.3.0] - 2026-01-26 ​

  • Added: Direct URL conversion and .urls batch file support
  • Added: Multi-strategy fetching (static/agent-browser/jina/auto) with a SQLite fetch cache and screenshot capture
  • Added: --no-cache-for <pattern> selective cache bypass; cache stats -v
  • Added: Official VitePress documentation website (bilingual)
  • Added: MIT License; CI/CD workflows

[0.2.4] - 2026-01-21 ​

  • Fixed: Office/PPTX compatibility patches; symlink-safety hardening; LLM empty-response retry; frontmatter field ordering

[0.2.3] - 2026-01-20 ​

  • Added: Persistent SQLite LLM cache with LRU eviction; cache stats/cache clear commands
  • Added: Vision-aware model routing; parallel PDF/image processing

[0.2.2] - 2026-01-20 ​

  • Added: constants.py module consolidating hardcoded values; broader unit test coverage

[0.2.1] - 2026-01-20 ​

  • Added: Per-file LLM usage/cost tracking; typed usage/asset models; cross-platform Office/LibreOffice detection
  • Changed: File-conflict renaming switched to .v2.md-style natural sort order

[0.2.0] - 2026-01-19 ​

  • Added: Monorepo rewrite: uv workspace, LiteLLM-based provider access, new converter/workflow architecture, JSON-schema-validated config
  • Breaking: New config format and CLI syntax; dropped support below Python 3.13; legacy src/markitai/ architecture removed

[0.1.6] - 2026-01-14 ​

  • Fixed: Model routing bugs; documentation accuracy pass

[0.1.5] - 2026-01-13 ​

  • Changed: Prompt management and cleaner-module refactor

[0.1.4] - 2026-01-13 ​

  • Fixed: LLM JSON-parsing edge cases; log formatting

[0.1.3] - 2026-01-12 ​

  • Changed: Adopted src layout; added CI workflow

[0.1.2] - 2026-01-12 ​

  • Added: Network resilience (retry/timeout handling); AI-assistant docs (CLAUDE.md, AGENTS.md)

[0.1.1] - 2026-01-11 ​

  • Changed: Major architecture refactor to a service-layer pattern

[0.1.0] - 2026-01-10 ​

  • Added: Capability-based model routing, lazy provider initialization, concurrent fallback on timeout, --fast execution mode, per-model batch statistics

[0.0.1] - 2026-01-08 ​

  • Added: Initial release: CLI (convert/batch/config/provider), Office/PDF/HTML conversion, 5 LLM providers with fallback, image processing, batch processing with resume