📰 Latest Articles & Guides

My Mermaid renderer drew tofu boxes instead of labels — the container had no fonts installed

My Mermaid renderer drew tofu boxes instead of labels — the container had no fonts installed
Agents write Mermaid constantly — sequence diagrams for API designs, flowcharts for release flows — but there's rarely anything on the other end that can display it. That gap is why I built a renderer: POST diagram syntax, get back a base64 PNG or SVG. I ended up packaging it as the Mermaid Diagram Renderer API ( https://x402.freeq.one/tools/mermaid.html ) so I could stop maintaining the render stack myself. The first version looked fine in my tests and broke immediately on real input. A long sequence diagram came back with intact boxes, arrows, and lifelines — and where every label should be, rows of empty rectangles. Tofu glyphs, top to bottom. The cause was my Dockerfile. I'd used a slim base image and installed headless Chromium for rendering, but no font packages. A browser without fonts can't rasterize text at all, so the renderer "worked" while drawing nothing legible. The subtle part: Mermaid sizes nodes by measuring text width before drawing it. With...

My OpenAPI differ flagged 200+ changes between two builds — array order was lying

My OpenAPI differ flagged 200+ changes between two builds — array order was lying
I maintain a small public API and my release notes used to be whatever I remembered an hour after deploying. So I wrote a deterministic OpenAPI differ: feed it a base spec and the new spec, get back a structured list of changes, no LLM in the loop to embellish anything. The first real run compared two builds three days apart and reported over 200 modifications. My integration tests all passed. Either the tests were useless or the differ was lying. It was the differ. I had parsed both specs into JSON and walked them recursively, comparing arrays by index. OpenAPI's paths object is nominally a map, but I was effectively diffing serialized key order — and when a developer inserted one new endpoint in the middle, every path after it lined up against the wrong neighbor. Same failure mode for the operations under each path and the parameters list. One tiny real change cascaded into a wall of false positives. The fix was switching every comparison to identity keys: paths keyed by the...

My PDF converter errored on a file every reader could open — owner passwords aren't open passwords

My PDF converter errored on a file every reader could open — owner passwords aren't open passwords
A legal team once sent my PDF-to-Markdown converter a 40-page services contract. Every desktop reader opened it fine. My converter threw File has not been decrypted . I assumed corruption. It wasn't. PDFs carry two independent passwords: a user password, needed to open the file, and an owner password, which only restricts printing, copying, and text extraction. Publishers and legal departments love owner passwords — the document opens everywhere but refuses to give up its text. My extraction library saw the encryption dictionary and stopped, even though the user password was empty. The fix turned out to be one deliberate line: when extraction fails with a not-decrypted error, call decrypt with an empty string before giving up. An empty user password is how these files ship — the viewer does the same trick silently, it just never mentions it. After `decrypt(""), every page extracted normally. The permissions flag turned out to be advisory, not security. But the deeper ...

My EPUB converter welded footnote paragraphs into mid-sentence text — the definitions hid inside the links

My EPUB converter welded footnote paragraphs into mid-sentence text — the definitions hid inside the links
I shipped my EPUB-to-Markdown converter after a week of clean tests on well-behaved novels. Confidence lasted until the first footnote-dense book hit the endpoint. The chapter that came back was unreadable. Every few sentences, a full paragraph of citation text sat inline, mid-thought, like the author had jammed an endnote into the paragraph without asking anyone. I assumed my extraction was broken at first — then I opened the raw XHTML and realized I'd built exactly the wrong thing, flawlessly. EPUB footnotes don't work the way you'd guess. The visible reference marker is a tiny <a epub:type="noteref"> anchor. The actual definition usually lives in a separate <aside epub:type="footnote"> near the end of the chapter or in backmatter. Fine — except plenty of publishers, especially titles converted from LaTeX or InDesign, embed the footnote text in a hidden span inside the noteref link itself , so screen readers and paste tools still get t...

max_tokens=700 on a reasoning model returned empty replies — hidden thinking tokens was the whole budget

max_tokens=700 on a reasoning model returned empty replies — hidden thinking tokens was the whole budget
For three nights straight my job-digest pipeline produced nothing. The pipeline pulls raw job-posting results, calls an LLM to write a 200-word digest, and stores the output. Mid-week I upgraded the digest call from a standard chat model to a reasoning model — smarter model, better summaries, or so I hoped. Every response came back with finish_reason: "length" and an empty content field. No error, no truncation warning. The request succeeded; the answer just wasn't there. The culprit: max_tokens . Seven hundred tokens is plenty for a 200-word digest on a chat model. But on reasoning models the completion budget includes the model's internal reasoning tokens — thousands of invisible thinking tokens get generated before the first visible character. My cap bought roughly 0% reasoning and 0% answer. The model burned the entire budget thinking and got cut off mid-thought. finish_reason honestly reported length , because it did hit a length limit — just not one I thou...

My URL-to-Markdown extractor returned a cookie banner as the article body — text density has no taste

⚡ INAPP
Most of my document converters take a file you already have. The URL-to-Markdown one is different: it fetches the page itself and has to decide what the "main content" even is before converting anything. That decision taught me my most humbling lesson so far. My first extraction heuristic was pure text density: score every candidate block by how much text it contains, pick the winner, convert it. It worked beautifully on blogs, docs and news pages. Then I ran a batch of real-world URLs through it, and one news site came back as a 600-word GDPR consent dialog — not the article. The actual story was ~300 words in a narrow column; the consent banner was boilerplate with more prose than the article itself. Text density has no taste. It cannot tell an article from a wall of legal boilerplate, because both are just "big blobs of text". Two signals fixed it. First, link density: nav headers, footers and cookie banners are stuffed with anchors, while real article text is...

My webhook catcher logged the same event six times — the sender's retry clock was shorter than my handler

My webhook catcher logged the same event six times — the sender's retry clock was shorter than my handler
I built an ephemeral webhook catcher because testing receivers is annoying: you need a public HTTPS URL, a running app, and logs, when all you actually want is to see the raw POST a sender makes. The catcher hands you a throwaway URL, accepts any POST for 24 hours, and lets you read the stored events back. Then I dogfooded it and found my own bug. A sender script I'd written posted a payload; my handler parsed the body, stored the JSON, and only then replied. Under a slow parse the round trip took longer than the sender's HTTP timeout. The sender's client gave up, treated it as a failed delivery, and retried. My "one payload per hook" test came back with six copies of the same event, each with a different arrival timestamp. Two lessons, both now baked into how I operate it: A receiver's latency budget is set by the caller. I was doing real work before responding. The fix is boring and old: enqueue, return 2xx in milliseconds, record asynchronously. Catchi...

My XLSX converter shipped a due date as 45852 — Excel dates are serial numbers, not strings

My XLSX converter shipped a due date as 45852 — Excel dates are serial numbers, not strings
I spotted this while testing a client's invoice pipeline: a spreadsheet whose "Due date" column displayed 14/07/2025 in Excel. My converter's Markdown output listed 45852 . Not a rendering bug — that's literally what the file stores. The stored value and the displayed value in a spreadsheet are two different things. Excel keeps dates as serial numbers: days elapsed since 1899-12-30. The odd anchor is a relic of Lotus 1-2-3, which believed 1900 was a leap year, so Excel pretends 1900-02-29 exists and serial 60 points at a date that never happened. The friendly dd/mm/yyyy you see lives in styles.xml as a number-format code attached to the cell's style — the sheet data just holds the raw float. Percentages are the same trap: 0.175 stored, "17.5%" displayed. Even booleans are stored as 1 and 0. So an agent reading my output had no way to know 45852 was a deadline. One downstream job happily treated it as a quantity and scheduled a reorder around it....

My QR PNGs looked sharp and refused to scan — pixels per module, not image size, is what matters

My QR PNGs looked sharp and refused to scan — pixels per module, not image size, is what matters
For months my QR generator's default PNG output was a flat 256px, and it worked — because everyone encoded short URLs. A typical short link lands in version 2–3 of the QR spec, roughly 25–29 modules across, so 256px gave ~8–10px per module and every phone scanner read it instantly. Then a user encoded a marketing link with tracking params — around 180 characters. The encoder picked a version 8 symbol: 49×49 modules. At 256px that's barely 5px per module, and my rasterizer antialiased the edges, so adjacent modules bled into each other. The image still looked like a perfectly normal QR code. It just wouldn't scan. Desktop scanners mostly managed; my phone failed about half of attempts at a normal distance. The lesson I'd missed: scanability is a function of pixels per module, not the image's nominal size. A 256px version 2 code and a 256px version 8 code are not the same product. On top of that, decoders expect a quiet zone — four modules of white margin on ever...

My git changelog listed a feature and its own revert in the same release — commit history isn't release contents

My git changelog listed a feature and its own revert in the same release — commit history isn't release contents
I built a git changelog generator because I was tired of LLM-written release notes inventing features. Mine is deterministic: it diffs two commits in a repo and emits a structured changelog. No model touches the history. The first real repo I ran it on humbled me: v1.3 to v1.4, 214 commits. The output read like a phone book. I counted by hand: 96 of the 214 were merge commits — Merge pull request #… , Merge branch 'release' into main . In a squash-merge repo those carry zero information; the squashed commit already has the PR title and description. git log --no-merges went into the pipeline and the list dropped to 118. One caveat: an "evil merge" can introduce changes that exist in no parent, so I only suppress merges whose message matches a recognizable template and keep the oddballs visible. Then a user did the real damage. Their v2.1 changelog listed "Added CSV export" and, further down, "Removed CSV export". Both commits were real, both in ...