<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: BellSal</title>
    <description>The latest articles on DEV Community by BellSal (@bellsal_b44bf6d).</description>
    <link>https://dev.to/bellsal_b44bf6d</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4079309%2Ff87dcc85-d87e-4a86-bb25-f05fae184a36.jpg</url>
      <title>DEV Community: BellSal</title>
      <link>https://dev.to/bellsal_b44bf6d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bellsal_b44bf6d"/>
    <language>en</language>
    <item>
      <title>Free PDF tools paywall the one button that matters. Here's why — and how I avoided it</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Fri, 21 Aug 2026 18:25:37 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/free-pdf-tools-paywall-the-one-button-that-matters-heres-why-and-how-i-avoided-it-59aa</link>
      <guid>https://dev.to/bellsal_b44bf6d/free-pdf-tools-paywall-the-one-button-that-matters-heres-why-and-how-i-avoided-it-59aa</guid>
      <description>&lt;p&gt;Almost every "free" PDF tool lets you merge, split, rotate and reorder pages for free — and then puts a paywall on the one button you actually came for: &lt;strong&gt;Export&lt;/strong&gt;. I got annoyed enough to build a PDF toolkit that doesn't, so here's the honest technical reason it happens and what it takes to avoid it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a PDF is cheap. Writing a valid one back is not.
&lt;/h2&gt;

&lt;p&gt;Rendering a PDF to show it on screen is a solved, cheap problem — the browser and a dozen libraries do it. The expensive part is going the other way: taking your edits and &lt;strong&gt;serializing a spec-valid PDF back out&lt;/strong&gt;. The PDF format is a cross-referenced object graph with an xref table, object streams, and byte-offset bookkeeping that has to stay internally consistent or the file won't open.&lt;/p&gt;

&lt;p&gt;That asymmetry is why so many tools happily show you a preview and let you rearrange thumbnails, then charge at export: the preview is free, the write-out is the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second reason: they uploaded your file to do it
&lt;/h2&gt;

&lt;p&gt;The other common pattern is a server round-trip. Your document goes up to their backend, a headless tool (Ghostscript, a licensed SDK, LibreOffice) does the manipulation, and the result comes back. That costs them real money &lt;strong&gt;per file&lt;/strong&gt; — CPU, bandwidth, storage — which is exactly why it eventually needs to cost &lt;em&gt;you&lt;/em&gt; money, and why the free tier is capped at "3 files a day."&lt;/p&gt;

&lt;p&gt;For a contract, an ID scan, or a payslip, silently shipping the document to someone's server is also a privacy problem people don't think about until later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it client-side with pdf-lib
&lt;/h2&gt;

&lt;p&gt;The fix for both problems is the same: never upload, and do the write-out in the browser. &lt;a href="https://github.com/Hopding/pdf-lib" rel="noopener noreferrer"&gt;&lt;code&gt;pdf-lib&lt;/code&gt;&lt;/a&gt; is the key piece — it can create and &lt;em&gt;modify&lt;/em&gt; existing PDFs entirely in JS, including the parts that are annoying to get right:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// merge: copy pages between documents, re-embedding fonts/resources&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copyPages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPageIndices&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addPage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// valid xref + object streams, in the browser&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;copyPages&lt;/code&gt; is the bit that matters: it deep-copies the page's resource dictionary (fonts, images, shared objects) into the target so the merged file is actually valid, not just concatenated bytes. The same document model handles split, extract/remove/reorder, rotate, N-up, page numbers and watermarks — all as object-graph edits, then one &lt;code&gt;save()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;A few things that bite:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compression / grayscale&lt;/strong&gt; aren't first-class in pdf-lib; you work at the content-stream and image-XObject level, which is fiddly but keeps it local.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OCR&lt;/strong&gt; (making a scanned PDF searchable) needs a separate WASM engine (Tesseract) — heavy, so lazy-load it only when the user actually asks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing existing body text&lt;/strong&gt; is the one thing you genuinely can't fake: PDF has no reflowable text model, so "change the words" still means a real editor like LibreOffice Draw. Be honest about that limit instead of pretending.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Client-side means the file never leaves the device, there's no per-file server cost, so &lt;strong&gt;the export can just be free&lt;/strong&gt; — permanently — and static hosting keeps it that way. That's the whole reason &lt;a href="https://pdfonlinefree.com" rel="noopener noreferrer"&gt;pdfonlinefree.com&lt;/a&gt; exists: merge, split, compress, sign, OCR, unlock, page numbers, watermark, JPG↔PDF — export included, nothing uploaded.&lt;/p&gt;

&lt;p&gt;If you're building something similar: put the expensive write-out in the browser, lazy-load the WASM parts, and don't paywall the button people came for.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I added gravity to a block puzzle to make players take risks — here's the design logic</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:15:56 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/i-added-gravity-to-a-block-puzzle-to-make-players-take-risks-heres-the-design-logic-2j2m</link>
      <guid>https://dev.to/bellsal_b44bf6d/i-added-gravity-to-a-block-puzzle-to-make-players-take-risks-heres-the-design-logic-2j2m</guid>
      <description>&lt;p&gt;Block puzzles have a math problem: the optimal strategy is almost always the safe one. Place each piece to keep the board as open as possible, clear a line when it's free, never gamble. It's solvable on autopilot, and autopilot is boring. I wanted a mechanic that made the greedy, risky play the &lt;em&gt;exciting&lt;/em&gt; one — without turning it into luck. Here's what I landed on and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mechanic: streaks trigger gravity
&lt;/h2&gt;

&lt;p&gt;The rule is one sentence: &lt;strong&gt;clear lines on consecutive turns, and at streak 3+ gravity switches on&lt;/strong&gt; — every remaining block falls to the bottom, and whatever new lines that forms clear too, chaining into cascade combos.&lt;/p&gt;

&lt;p&gt;That one rule flips the incentive. Normally you clear a line the moment you can, to stay safe. But now, breaking your streak to make a safe clear &lt;em&gt;costs&lt;/em&gt; you the gravity payoff. So the interesting question becomes: do I take the small safe clear now, or hold the streak one more turn for a cascade that could clear half the board? That tension is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why cascades, specifically
&lt;/h2&gt;

&lt;p&gt;Cascades solve two problems at once:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;They reward planning without requiring perfect play.&lt;/strong&gt; You don't have to calculate the exact cascade — you just have to keep the streak alive and stack toward the bottom. The gravity does the satisfying part. That keeps it accessible while still having depth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They create "juice" for free.&lt;/strong&gt; A cascade is a chain reaction: one clear causes a fall causes another clear. Chain reactions feel great with almost no extra design — the game does something bigger than the move you made. That dopamine hit is what makes people play "one more round."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trap I avoided: making the cascade &lt;em&gt;random&lt;/em&gt;. If gravity dropped blocks in unpredictable ways, it'd feel like a slot machine and players would stop trusting their decisions. Gravity here is fully deterministic — same board, same fall, every time. The skill is in setting it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Difficulty comes from the board, not from speed
&lt;/h2&gt;

&lt;p&gt;A cheap way to add difficulty is to speed things up until the player's reflexes fail. I didn't want that — it punishes older or casual players and it's not &lt;em&gt;interesting&lt;/em&gt; difficulty. Instead the pressure comes from obstacles that change how the board behaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Asteroids&lt;/strong&gt; take two hits to clear, so they clog a line you were counting on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ice blocks&lt;/strong&gt; freeze in place and can't move, forcing you to route around them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reward cubes&lt;/strong&gt; drop a power-up when destroyed, so there's a reason to &lt;em&gt;want&lt;/em&gt; to clear a spot you'd otherwise avoid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these makes you re-plan rather than react faster. Difficulty as a puzzle, not as a stopwatch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modes as difficulty curves, not content padding
&lt;/h2&gt;

&lt;p&gt;Same core rules, four framings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zen&lt;/strong&gt; — no timer, no fail state, no ads. This is the mode people actually relax with, and it's the one I'd defend hardest. A puzzle game needs a mode where the game isn't trying to extract anything from you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timed&lt;/strong&gt; — the streak tension plus a clock, for people who want the pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Endless&lt;/strong&gt; — score-chasing, where cascades compound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Daily&lt;/strong&gt; — one fixed board for everyone, which turns a solo puzzle into a shared conversation ("did you get the triple cascade on today's?").&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The lesson: modes aren't content, they're &lt;em&gt;difficulty and mood curves&lt;/em&gt; over the same ruleset. One mechanic, four audiences.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway for anyone building a puzzle game
&lt;/h2&gt;

&lt;p&gt;If your puzzle has one obvious optimal strategy, players will find it and leave. The fix isn't more content — it's a single mechanic that makes the &lt;em&gt;risky&lt;/em&gt; line competitive with the safe one, and that pays off in a way the game animates for you. For me that was streak-triggered gravity. Make the greedy play tempting, keep the payoff deterministic so it stays skill, and let obstacles — not speed — carry the difficulty.&lt;/p&gt;

&lt;p&gt;The game is &lt;a href="https://play.google.com/store/apps/details?id=com.positivevibration.cosmicblocks" rel="noopener noreferrer"&gt;Cosmo Blocks&lt;/a&gt; (&lt;a href="https://apps.apple.com/us/app/cosmo-blocks-puzzle-blast/id6787754622" rel="noopener noreferrer"&gt;iOS here&lt;/a&gt;) — disclosure, it's mine, and everything above is the actual design running in it. Free, and Zen mode has no ads if you just want to see the cascade thing without a timer breathing on you.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>flutter</category>
      <category>gamedesign</category>
    </item>
    <item>
      <title>Shipping a Flutter arcade game to both app stores with AdMob and consent — 4 things that bit me</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Thu, 20 Aug 2026 18:10:14 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/shipping-a-flutter-arcade-game-to-both-app-stores-with-admob-and-consent-4-things-that-bit-me-21k5</link>
      <guid>https://dev.to/bellsal_b44bf6d/shipping-a-flutter-arcade-game-to-both-app-stores-with-admob-and-consent-4-things-that-bit-me-21k5</guid>
      <description>&lt;p&gt;I built a small arcade puzzle game in Flutter and shipped it to Google Play and the App Store from one codebase. The gameplay was the easy part. Monetizing it with ads &lt;em&gt;without&lt;/em&gt; making the game feel like a slot machine, and getting the privacy consent right, is where the real work hid. Here are the four things that actually broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The interstitial that showed up at the worst possible moment
&lt;/h2&gt;

&lt;p&gt;The naive way to add an interstitial is: game over, show ad. Ship that and players hate you, because the ad fires the instant they die — before they've even processed the loss — and again on every single retry.&lt;/p&gt;

&lt;p&gt;Two rules fixed it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Never on the first game over.&lt;/strong&gt; A new player who eats a fullscreen ad ten seconds in just uninstalls. Gate it behind a play counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit it in wall-clock time, not by count.&lt;/strong&gt; Track the timestamp of the last interstitial and refuse to show another within N minutes, regardless of how many times the player died.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_lastInterstitial&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;_gameOvers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="nf"&gt;_canShowInterstitial&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;_gameOvers&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_gameOvers&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// let them settle in first&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_lastInterstitial&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;difference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;minutes:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The counter and timestamp both have to persist, or a player who force-quits between rounds sees an ad every launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Consent has to run &lt;em&gt;before&lt;/em&gt; the first ad request, not alongside it
&lt;/h2&gt;

&lt;p&gt;If you serve ads in the EU/UK you need a consent flow (Google's User Messaging Platform, UMP). The mistake I made first: kicking off consent and ad initialization in parallel at startup. That's a race. Sometimes the first banner requested before consent resolved, which is exactly the thing the consent form exists to prevent.&lt;/p&gt;

&lt;p&gt;The correct order is strictly sequential: request the consent info, load and show the form if required, and only &lt;em&gt;after&lt;/em&gt; that initialize the Mobile Ads SDK and request anything.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ConsentRequestParameters&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;requestConsentInfoUpdate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kd"&gt;async&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;ConsentInformation&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isConsentFormAvailable&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;_loadAndShowConsentFormIfRequired&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ONLY now:&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;MobileAds&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;instance&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="n"&gt;_loadBanner&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fail open: init ads without personalization */&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it by forcing an EEA geography in the UMP debug settings, not just on your own device in your own country — otherwise you'll never see the form and think it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The rewarded video that closed early left players stuck
&lt;/h2&gt;

&lt;p&gt;I used a rewarded ad to grant an extra life. The bug: if the user dismisses the ad early, the "reward earned" callback never fires, but my code was waiting on it to resume the game. Result: a dead screen.&lt;/p&gt;

&lt;p&gt;The fix is to treat &lt;em&gt;earned&lt;/em&gt; and &lt;em&gt;closed&lt;/em&gt; as two independent events, and always have a path forward on dismissal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;earned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;fullScreenContentCallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FullScreenContentCallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nl"&gt;onAdDismissedFullScreenContent:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;dispose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="n"&gt;earned&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;_grantExtraLife&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_returnToGameOver&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// never just hang&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;ad&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;show&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;onUserEarnedReward:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;earned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Race conditions around ad lifecycle callbacks are the single most common source of "the game froze" reports. Assume every callback might not fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Test ads in debug, real ads only in release — enforced by the build, not by memory
&lt;/h2&gt;

&lt;p&gt;The classic disaster is shipping a build that still points at test ad unit IDs (zero revenue) — or worse, developing against your &lt;em&gt;real&lt;/em&gt; units and getting flagged for invalid traffic by clicking your own ads.&lt;/p&gt;

&lt;p&gt;Don't rely on remembering to swap IDs. Wire it to the build mode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;bannerUnitId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kReleaseMode&lt;/span&gt;
  &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s"&gt;'ca-app-pub-REAL/REAL'&lt;/span&gt;
  &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'ca-app-pub-3940256099942544/6300978111'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// Google's official test ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Debug and profile builds physically cannot serve real ads; release builds physically cannot serve test ads. It's one line and it removes an entire category of mistake.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;None of this is about ad revenue being high — for a free game it rarely is. It's that the difference between a game people keep and one they uninstall in a day is almost entirely in &lt;em&gt;when&lt;/em&gt; and &lt;em&gt;how&lt;/em&gt; the ads appear, and whether the lifecycle callbacks are handled defensively. Get the pacing and the consent order right and the ads become almost invisible; get them wrong and no amount of good gameplay survives it.&lt;/p&gt;

&lt;p&gt;The game this came from is &lt;a href="https://play.google.com/store/apps/details?id=com.gridzap.game" rel="noopener noreferrer"&gt;GridZap&lt;/a&gt; — an arcade puzzle where you trace grid lines to close squares while enemies chase you (&lt;a href="https://apps.apple.com/us/app/gridzap/id6787287669" rel="noopener noreferrer"&gt;iOS here&lt;/a&gt;). Disclosure: it's mine, and every code pattern above is what actually ships in it. Zen of the whole thing: a banner, one interstitial between levels, and an optional rewarded video — nothing more.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>gamedev</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Your canvas "A4 export" is not A4: DPI, print size, and the number nobody checks</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Wed, 19 Aug 2026 22:12:08 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/your-canvas-a4-export-is-not-a4-dpi-print-size-and-the-number-nobody-checks-5akh</link>
      <guid>https://dev.to/bellsal_b44bf6d/your-canvas-a4-export-is-not-a4-dpi-print-size-and-the-number-nobody-checks-5akh</guid>
      <description>&lt;p&gt;When I added an "A4" preset to a browser-based collage tool, I thought the hard part was the layout maths. It wasn't. The hard part was that &lt;strong&gt;"A4" on a canvas is a shape, not a size&lt;/strong&gt; — and if you don't say that out loud, your users find out at the print shop.&lt;/p&gt;

&lt;p&gt;Here is what I got wrong, and the numbers that matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A canvas has no physical size
&lt;/h2&gt;

&lt;p&gt;An HTML canvas is a pixel grid. It has width and height in pixels and nothing else. Paper has millimetres. The bridge between them is DPI, and the canvas has no opinion about it.&lt;/p&gt;

&lt;p&gt;So when a preset says "A4", all it can honestly promise is the &lt;em&gt;aspect ratio&lt;/em&gt; — 1:1.414, the ratio every ISO 216 paper size shares. What those pixels become on paper depends entirely on how many of them you exported.&lt;/p&gt;

&lt;p&gt;My preset exports 1414 x 2000 px. That is exactly the A4 ratio. Now convert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A4 = 210 x 297 mm = 8.27 x 11.69 inches

1414 px / 8.27 in = 171 DPI
2000 px / 11.69 in = 171 DPI
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;171 DPI.&lt;/strong&gt; Not 300. For a home or office printer that is genuinely fine — you will not see individual pixels at normal viewing distance. For a photo lab, it is under half the data they expect.&lt;/p&gt;

&lt;p&gt;To hit the 300 DPI that print shops assume, the same page needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;8.27 in x 300 = 2480 px
11.69 in x 300 = 3508 px
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2480 x 3508 is &lt;strong&gt;3.1x more pixels&lt;/strong&gt; than 1414 x 2000. That is the whole trade-off in one line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just export at 300 DPI then?
&lt;/h2&gt;

&lt;p&gt;Because of memory, and because phones are where people actually use this.&lt;/p&gt;

&lt;p&gt;A 2480 x 3508 canvas is 8.7 million pixels. The browser holds it as RGBA, 4 bytes per pixel, so the bitmap alone is about &lt;strong&gt;35 MB&lt;/strong&gt; before you have drawn anything into it. Now add the source photos: a 12-photo collage where each source is a 12-megapixel phone image is another 576 MB if you keep them all decoded at full size.&lt;/p&gt;

&lt;p&gt;That is how you get the crash that only ever happens on someone else's Android phone.&lt;/p&gt;

&lt;p&gt;The fix that made this survivable was downscaling every source image on import, before compositing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_SOURCE_EDGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2400&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fitSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;longest&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_SOURCE_EDGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_SOURCE_EDGE&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;longest&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A source image never needs more pixels than the region it will occupy in the output. If a photo lands in a quarter of a 1414 x 2000 page, anything beyond ~1000 px on its long edge is decoded, held in memory, and then thrown away by the scaler. Capping the long edge at 2400 px costs nothing visible and removes most of the memory pressure.&lt;/p&gt;

&lt;p&gt;Worth knowing: iOS Safari also caps total canvas area (historically around 16.7 million pixels on older devices). Exceed it and you don't get an exception — you get a blank canvas. Silent failure is the theme of this whole area.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not technical
&lt;/h2&gt;

&lt;p&gt;None of the above is what users complain about. They complain because they expected "A4" to mean "this will print correctly", and nobody told them otherwise.&lt;/p&gt;

&lt;p&gt;So the fix that mattered most was a sentence in the UI, not a line of code: say the export size in pixels, say roughly what DPI that is at A4, and say plainly that it suits a home printer rather than a photo lab. People are completely fine with a limitation they were told about in advance. They are not fine with discovering it after paying for prints.&lt;/p&gt;

&lt;p&gt;Two more things worth surfacing while you are at it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Printer margins.&lt;/strong&gt; Most consumer printers cannot print to the edge and will shrink or crop your page to fit their unprintable margin — usually a few millimetres. A layout that looks perfectly centred on screen comes back visibly off-centre. Either design in a safe margin or warn about "borderless" mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orientation.&lt;/strong&gt; 1414 x 2000 is portrait. Users who want landscape A4 need 2000 x 1414, and if you only ship one, half of them will rotate the image in a photo editor afterwards and lose quality doing it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you export anything meant for paper from a canvas, do this arithmetic once and put the result in your interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;DPI = pixel width / physical width in inches
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It takes ten seconds and it is the difference between a tool people trust and one they stop using after a bad print.&lt;/p&gt;

&lt;p&gt;The tool this came from is &lt;a href="https://freecollageimage.com" rel="noopener noreferrer"&gt;freecollageimage.com&lt;/a&gt; — free, browser-only, nothing is uploaded. Full disclosure: it's mine, and the 171 DPI number above is its actual current export, not a hypothetical.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The HTML canvas quietly deletes your photo's metadata — and one day that bites you</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Tue, 18 Aug 2026 20:05:32 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/the-html-canvas-quietly-deletes-your-photos-metadata-and-one-day-that-bites-you-2h68</link>
      <guid>https://dev.to/bellsal_b44bf6d/the-html-canvas-quietly-deletes-your-photos-metadata-and-one-day-that-bites-you-2h68</guid>
      <description>&lt;p&gt;I build browser-based image tools, and the single most confusing class of bug reports I get all trace back to the same fact: the moment a photo passes through a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;, its metadata is gone. No EXIF, no GPS, no color profile, no orientation flag. The canvas carries pixels and nothing else.&lt;/p&gt;

&lt;p&gt;Most of the time that's a feature. Sometimes it's a silent disaster. Here's both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens
&lt;/h2&gt;

&lt;p&gt;When you draw an image to a canvas and read it back out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalHeight&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// or toBlob&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;out&lt;/code&gt; is a brand-new JPEG encoded from raw pixels. Every ancillary chunk the original file carried — the EXIF block, the IPTC/XMP metadata, the ICC color profile — never existed in the canvas, so none of it makes it into the output. There's no flag to preserve it. The pixels are all the canvas ever had.&lt;/p&gt;

&lt;h2&gt;
  
  
  The good side: free metadata stripping
&lt;/h2&gt;

&lt;p&gt;This is genuinely useful. If you run a photo through any canvas-based compressor, resizer or format converter, the output is automatically clean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The GPS coordinates that say where you took the photo? Gone.&lt;/li&gt;
&lt;li&gt;The camera serial number, the timestamp, the "edited by X"? Gone.&lt;/li&gt;
&lt;li&gt;The little privacy leak that is a raw phone photo? Gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a browser tool that re-encodes an image is also, for free, a metadata stripper — whether or not that's advertised as the point. I lean on this on purpose: "compress this image" and "scrub this image before I post it" become the same operation, done locally, with nothing uploaded.&lt;/p&gt;

&lt;p&gt;One caveat worth stating loudly: this only protects you if the tool processes &lt;strong&gt;locally&lt;/strong&gt;. If the page uploads your file to a server to do the work, you've handed the metadata to that server on the way to deleting it. "Runs in the browser" is the part that matters, not "strips EXIF."&lt;/p&gt;

&lt;h2&gt;
  
  
  The bad side: the orientation bug that eats an afternoon
&lt;/h2&gt;

&lt;p&gt;Here's the one that generates the confused bug reports. iPhones (and many other cameras) don't rotate the pixels when you turn the phone. They store the image in the sensor's native orientation and set an &lt;strong&gt;EXIF Orientation&lt;/strong&gt; flag — "display this rotated 90°." Your photo viewer reads that flag and rotates on display. The pixels themselves are sideways.&lt;/p&gt;

&lt;p&gt;Now send that image through a canvas. The canvas draws the raw pixels — sideways — and the orientation flag, like all metadata, is dropped. Your output is a photo that is now permanently rotated the wrong way, with no flag left to correct it.&lt;/p&gt;

&lt;p&gt;The infuriating part is that it looks fine in your &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; preview, because the browser applied the orientation flag when it displayed the original. It only breaks &lt;em&gt;after&lt;/em&gt; the canvas round-trip. So the user sees a correct preview, downloads the result, and it's rotated. Classic "works on my screen."&lt;/p&gt;

&lt;p&gt;The fix, since 2020-ish, is one line. Tell the browser to bake the orientation into the pixels when it decodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createImageBitmap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;imageOrientation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;from-image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// now draw `bitmap` to the canvas — pixels are already upright&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or, if you draw an &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; element instead, CSS &lt;code&gt;image-orientation: from-image&lt;/code&gt; (the default in modern browsers) fixes the &lt;em&gt;display&lt;/em&gt; — but &lt;strong&gt;not&lt;/strong&gt; what &lt;code&gt;drawImage&lt;/code&gt; reads. &lt;code&gt;drawImage&lt;/code&gt; still takes the raw pixels. So for canvas work, &lt;code&gt;createImageBitmap&lt;/code&gt; with &lt;code&gt;imageOrientation: "from-image"&lt;/code&gt; is the reliable route. Test it with a portrait photo taken on a phone held sideways; a landscape screenshot will never reveal the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I settled on
&lt;/h2&gt;

&lt;p&gt;Two sentences I now keep in my head whenever a canvas touches user images:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assume all metadata is lost the instant you &lt;code&gt;drawImage&lt;/code&gt;.&lt;/strong&gt; If you need to keep any of it (orientation, color profile, a copyright tag), handle it explicitly before the round-trip — don't hope it survives, it won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat the loss as a privacy win only if the processing is local.&lt;/strong&gt; Uploading to strip is theatre.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see both sides in practice, both tools are mine and free: an &lt;a href="https://imageonlinefree.com/tools/exif-viewer.html" rel="noopener noreferrer"&gt;EXIF viewer&lt;/a&gt; to see exactly what your photos are carrying, and the &lt;a href="https://imageonlinefree.com/tools/compress-image.html" rel="noopener noreferrer"&gt;compressor&lt;/a&gt; that drops all of it while it shrinks the file — both running entirely in the browser, nothing uploaded.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>showdev</category>
    </item>
    <item>
      <title>One codebase, one website, two app stores: what Capacitor actually costs you</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Mon, 17 Aug 2026 14:44:05 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/one-codebase-one-website-two-app-stores-what-capacitor-actually-costs-you-2igc</link>
      <guid>https://dev.to/bellsal_b44bf6d/one-codebase-one-website-two-app-stores-what-capacitor-actually-costs-you-2igc</guid>
      <description>&lt;p&gt;My photo collage editor was a static site: vanilla JS, a canvas, no framework. Putting it on the Play Store and the App Store with Capacitor took a weekend of work and about three weeks of small surprises. The surprises are the interesting part, so here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't ship the website inside the app
&lt;/h2&gt;

&lt;p&gt;The instinct is to point Capacitor at your existing web root and be done. Don't. A website root contains a pile of things that make no sense inside an app and some that will actively hurt you: &lt;code&gt;sitemap.xml&lt;/code&gt;, &lt;code&gt;robots.txt&lt;/code&gt;, a service worker, a cookie consent script, landing pages, &lt;code&gt;llms.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I ended up with a build step that generates a separate app-only bundle. It copies the public folder while filtering out every &lt;code&gt;.html&lt;/code&gt; file and an explicit deny list of site-only assets, then rebuilds a single &lt;code&gt;index.html&lt;/code&gt; from a native shell template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;siteOnlyFiles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ads.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;consent-tracking.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;home-seo.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;llms.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;manifest.webmanifest&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pwa.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;robots.txt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;service-worker.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sitemap.xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;social-preview.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;

&lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cpSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;outputDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;recursive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;publicDir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;siteOnlyFiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then it pulls the editor markup out of the website's homepage by slicing between two known markers and injects it into the native template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workspaceStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteHtml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;section class="workspace editor-workspace"&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;workspaceEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;siteHtml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;section class="content-band"&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;workspaceStart&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;workspaceStart&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;workspaceEnd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not extract the shared collage editor from index.html.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, this is string-slicing HTML, and yes, that is usually a terrible idea. The reason I kept it: the &lt;code&gt;throw&lt;/code&gt; turns a refactor of the homepage into a &lt;strong&gt;failed build&lt;/strong&gt; rather than an app that ships with an empty screen. A brittle step that fails loudly beats a clever one that fails silently. If you do this, make the failure mode explicit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word "free" can get your build flagged
&lt;/h2&gt;

&lt;p&gt;This one I did not see coming. My translation file is shared between the site and the app, and the site copy says things like "free, no signup, no watermark" because that is what the landing page is selling.&lt;/p&gt;

&lt;p&gt;Store metadata scanners read the strings inside your bundle. Price language in shipped assets is a good way to get a conversation you don't want with a review team. The build step now rewrites those specific strings for the packaged version: the app says "Create photo collages", the website keeps its copy.&lt;/p&gt;

&lt;p&gt;If you share an i18n file between a marketing site and an app, audit it for price and promotional wording before you submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The download button that does nothing
&lt;/h2&gt;

&lt;p&gt;The single biggest functional break. On the web, saving the collage was an &lt;code&gt;&amp;lt;a download&amp;gt;&lt;/code&gt; pointing at a data URL. In an Android WebView that silently does nothing: no error, no console warning, no download. The button just doesn't work.&lt;/p&gt;

&lt;p&gt;The fix was a small native plugin that writes to MediaStore, plus a shim that intercepts the anchor click so the shared editor code never has to know which environment it's in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;originalClick&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;HTMLAnchorElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;HTMLAnchorElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^data:image&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;saveDataUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// native plugin -&amp;gt; MediaStore&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;originalClick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Monkey-patching a DOM prototype is not something I'd normally advocate, but the alternative was branching every save path in shared code. I wrote about that bug in more detail in an earlier post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capacitor 8 wants JDK 21, and your machine probably has 17
&lt;/h2&gt;

&lt;p&gt;My system &lt;code&gt;JAVA_HOME&lt;/code&gt; pointed at JDK 17, which produced a Gradle error that does not obviously say "wrong Java version". The build script now finds a JDK 21 itself (Android Studio bundles one in &lt;code&gt;jbr&lt;/code&gt;) and only falls back to &lt;code&gt;JAVA_HOME&lt;/code&gt; after verifying it actually is 21:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isJdk21&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;execFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;java.exe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;encoding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;stdio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ignore&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pipe&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/version "21|openjdk 21|build 21/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the version instead of trusting the variable took ten minutes and has saved me the same debugging session at least three times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debug builds must force test ads
&lt;/h2&gt;

&lt;p&gt;If you monetise with AdMob, tapping your own real ad while testing on your own phone is a policy violation. Rather than remembering not to tap things, the debug build rewrites the ad config in the generated bundle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;debug&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admob.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;premium.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;android-web&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IS_TESTING: false&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;IS_TESTING: true&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Make the safe thing automatic, because you will forget.&lt;/p&gt;

&lt;h2&gt;
  
  
  You don't need a Mac for the iOS build
&lt;/h2&gt;

&lt;p&gt;I built and shipped the iOS version from a Windows machine. Codemagic runs the Mac, and the whole config is short enough to read in one screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ios-release&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;instance_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mac_mini_m2&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;node&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;
      &lt;span class="na"&gt;ios_signing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;distribution_type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_store&lt;/span&gt;
        &lt;span class="na"&gt;bundle_identifier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.example.app&lt;/span&gt;
    &lt;span class="na"&gt;scripts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build:android-web&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx cap sync ios&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;xcode-project use-profiles&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;xcode-project build-ipa --project ios/App/App.xcodeproj --scheme App&lt;/span&gt;
    &lt;span class="na"&gt;publishing&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app_store_connect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;integration&lt;/span&gt;
        &lt;span class="na"&gt;submit_to_testflight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: running &lt;code&gt;npx cap sync ios&lt;/code&gt; on Windows rewrites paths in &lt;code&gt;Package.swift&lt;/code&gt; in a way that macOS won't accept. Let the CI machine do the iOS sync and don't commit the result from Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;For a canvas-based tool with no server, yes. The editor is genuinely the same code in all three places, and a bug fix ships everywhere. The cost was not the packaging, it was the seams: file saving, ad policy, store metadata, and a build pipeline that now has opinions about which files belong where.&lt;/p&gt;

&lt;p&gt;What I would tell past me: budget your time for the platform boundaries, not for the port. The port is a weekend. The boundaries are the rest of the month.&lt;/p&gt;

&lt;p&gt;The result, if you want to poke at it: &lt;a href="https://freecollageimage.com/" rel="noopener noreferrer"&gt;freecollageimage.com&lt;/a&gt;, and the same editor as an &lt;a href="https://play.google.com/store/apps/details?id=com.freecollageimage.app" rel="noopener noreferrer"&gt;Android app&lt;/a&gt; and an &lt;a href="https://apps.apple.com/us/app/collageimage-photo-collage/id6790397567" rel="noopener noreferrer"&gt;iOS app&lt;/a&gt;. All mine, all free.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>android</category>
      <category>ios</category>
    </item>
    <item>
      <title>Why HEIC breaks every browser-based image tool, and what it costs to fix</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Sun, 16 Aug 2026 14:00:40 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/why-heic-breaks-every-browser-based-image-tool-and-what-it-costs-to-fix-5g4g</link>
      <guid>https://dev.to/bellsal_b44bf6d/why-heic-breaks-every-browser-based-image-tool-and-what-it-costs-to-fix-5g4g</guid>
      <description>&lt;p&gt;Every image tool that runs in the browser hits the same wall eventually: someone drops in a photo straight off an iPhone and nothing happens. No error, no preview, no conversion. The file is HEIC, and the browser has no idea what to do with it.&lt;/p&gt;

&lt;p&gt;This is worth writing up because the workarounds people reach for first are all wrong in interesting ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the browser can't help you
&lt;/h2&gt;

&lt;p&gt;HEIC is a HEIF container holding HEVC-encoded image data. HEVC is patent-encumbered, and that has kept it out of browsers: Chrome, Firefox and Edge cannot decode it. Safari can, because Apple already licenses HEVC for the rest of the platform — which is exactly why this bug is invisible if you develop on a Mac and test in Safari.&lt;/p&gt;

&lt;p&gt;So the usual pipeline just fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Image&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;img&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// rejects on HEIC in Chrome, works in Safari&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;img.decode()&lt;/code&gt; rejects with a decode error. &lt;code&gt;canvas.drawImage()&lt;/code&gt; on a never-loaded image draws nothing. If you're not awaiting the decode, you get a silently blank canvas instead of an exception, which is worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: renaming the extension
&lt;/h2&gt;

&lt;p&gt;Users do this constantly, and so do developers when they're debugging in a hurry. Renaming &lt;code&gt;IMG_4021.HEIC&lt;/code&gt; to &lt;code&gt;IMG_4021.jpg&lt;/code&gt; changes nothing — the bytes are still HEIF, and anything that sniffs the actual content rejects the file. Reddit, most upload forms and every real decoder look at magic bytes, not at the name.&lt;/p&gt;

&lt;p&gt;If you want to detect HEIC properly, read the header. The &lt;code&gt;ftyp&lt;/code&gt; box sits at offset 4 and the brand tells you what you have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isHeic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;head&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;boxType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;   &lt;span class="c1"&gt;// 'ftyp'&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;brand&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromCharCode&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;  &lt;span class="c1"&gt;// 'heic','heix','mif1','msf1'&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;boxType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ftyp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;heic|heix|hevc|mif1|msf1&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not rely on &lt;code&gt;file.type&lt;/code&gt; for this, which brings us to the second trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;file.type&lt;/code&gt; is often an empty string
&lt;/h2&gt;

&lt;p&gt;This one cost me a long time. Files arriving from iOS — through the share sheet, some pickers, occasionally AirDrop — frequently have &lt;code&gt;file.type === ''&lt;/code&gt;. Not &lt;code&gt;image/heic&lt;/code&gt;, not &lt;code&gt;image/jpeg&lt;/code&gt;. Nothing.&lt;/p&gt;

&lt;p&gt;Which means the validation almost everyone writes first silently rejects perfectly good photos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// rejects real images from iPhones&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not an image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user sees a file they know is a photo being refused as "not an image", and there is nothing in the console to explain it. The fix is to treat an empty type as unknown rather than as invalid, and fall back to the extension or the header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksLikeImage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g|png|webp|gif|heic|heif&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What decoding actually costs
&lt;/h2&gt;

&lt;p&gt;If your tool's whole premise is that files stay on the device, you can't punt HEIC to a server. So you ship a decoder to the client — usually &lt;code&gt;libheif&lt;/code&gt; compiled to WebAssembly, via &lt;code&gt;libheif-js&lt;/code&gt; or &lt;code&gt;heic2any&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It works. It is also the single heaviest thing in the bundle: a few hundred kilobytes of WASM that the vast majority of visitors will never need, because most people aren't uploading HEIC. Loading that eagerly to serve a minority is the wrong trade.&lt;/p&gt;

&lt;p&gt;Lazy-load it behind the detection above, so the cost only lands on the users who actually have a HEIC file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toJpegBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;isHeic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// no decoder needed&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;heic2any&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;heic2any&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// fetched now, not at boot&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;heic2any&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;toType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth knowing once it runs. Decoding is CPU-bound and blocks the main thread — a 12-megapixel photo can freeze the tab for a noticeable moment, so a Web Worker is worth the trouble if you handle batches. And Live Photos are HEIC files with multiple images inside; most decoders hand you the primary one, which is usually what people expect, but not always what they wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orientation, since you're already here
&lt;/h2&gt;

&lt;p&gt;HEIC photos carry EXIF orientation, and once you've decoded to a canvas that metadata is gone. Portrait photos come out sideways. If you decode to a bitmap yourself, apply the rotation before export; &lt;code&gt;createImageBitmap&lt;/code&gt; accepts &lt;code&gt;{ imageOrientation: 'from-image' }&lt;/code&gt; for exactly this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the problem
&lt;/h2&gt;

&lt;p&gt;Every failure here is silent. A rejected decode with no catch, an empty MIME type that reads as invalid, a renamed file that looks converted, an orientation tag dropped on the floor. Nothing throws, and the user just sees a tool that doesn't work with their photos.&lt;/p&gt;

&lt;p&gt;That's the general lesson, and it's the same one I ran into with the mobile save button: the platform boundaries — file types, downloads, share sheets, permissions — are where things fail quietly, and where checking the outcome on a real device matters more than reading the code.&lt;/p&gt;

&lt;p&gt;For context, this came out of building &lt;a href="https://www.imageonlinefree.com" rel="noopener noreferrer"&gt;imageonlinefree.com&lt;/a&gt;, which converts HEIC locally. The failure modes are the transferable part.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ios</category>
      <category>showdev</category>
    </item>
    <item>
      <title>What actually breaks when you process PDFs in the browser</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Sat, 15 Aug 2026 20:38:57 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/what-actually-breaks-when-you-process-pdfs-in-the-browser-4o91</link>
      <guid>https://dev.to/bellsal_b44bf6d/what-actually-breaks-when-you-process-pdfs-in-the-browser-4o91</guid>
      <description>&lt;p&gt;Doing PDF work entirely in the browser is a nicer story than it sounds. &lt;code&gt;pdf-lib&lt;/code&gt; loads a document from an &lt;code&gt;ArrayBuffer&lt;/code&gt;, gives you pages as objects, and hands back bytes — no server, no upload, no retention policy anyone has to trust. The happy path is about fifteen lines.&lt;/p&gt;

&lt;p&gt;The unhappy paths are where the actual engineering is, and none of them announce themselves. Here are the four that cost me the most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Encrypted PDFs can't be modified, and most tools lie about it
&lt;/h2&gt;

&lt;p&gt;This is the one that generates support emails.&lt;/p&gt;

&lt;p&gt;A PDF can carry two different passwords. A &lt;em&gt;user password&lt;/em&gt; stops you opening the file at all — obvious, and users understand it. An &lt;em&gt;owner password&lt;/em&gt; lets anyone open and read the document while restricting what can be done to it. Files that come out of banks, insurers and government portals very often have one, and nothing in the viewer tells you.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pdf-lib&lt;/code&gt; will refuse to load such a document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pdf-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// "Input document to `PDFDocument.load` is encrypted."&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an &lt;code&gt;ignoreEncryption: true&lt;/code&gt; option, and it is a trap. It gets you past the exception, and the object you get back is structurally incomplete. Merge it with another document and you produce a file that opens to blank pages, or doesn't open at all. The operation "succeeded" and the output is garbage.&lt;/p&gt;

&lt;p&gt;Plenty of online PDF tools take exactly that shortcut. You upload a protected file, the spinner runs, you get a download, and the result is broken — with no explanation, because from the tool's point of view nothing failed.&lt;/p&gt;

&lt;p&gt;The honest handling is to detect the condition and say so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pdf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;PDFDocument&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;encrypted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserFacingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This PDF is password-protected and cannot be modified. &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Remove the protection in the application that produced it, then try again.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a worse-looking outcome and a much better one. "I can't do this, here's why" beats a corrupted file every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The memory ceiling is real, and it kills the tab silently
&lt;/h2&gt;

&lt;p&gt;Everything lives in the tab. The original &lt;code&gt;ArrayBuffer&lt;/code&gt;, the parsed object graph, the serialised output — at peak you are holding several copies of the document at once. A 300-page scan at 300 dpi is a few hundred megabytes before you've done anything useful, and on mobile the tab just dies. No exception you can catch, no &lt;code&gt;onerror&lt;/code&gt;, nothing to report. The page is simply gone.&lt;/p&gt;

&lt;p&gt;So the size guard has to come &lt;em&gt;before&lt;/em&gt; parsing, not after:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BYTES&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UserFacingError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`This file is &lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1048576&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; MB. `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`Browser memory tops out well below that — PDFsam or Stirling PDF on your desktop `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`will handle it comfortably.`&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naming a tool that does the job better is not a defeat. Someone with a 400 MB scan is not going to be served by any browser, and pretending otherwise wastes their afternoon.&lt;/p&gt;

&lt;p&gt;Two things that help below the ceiling: process sequentially rather than loading every input at once, and drop references as you go so the collector can actually reclaim them. Merging ten files one at a time uses a fraction of the peak of merging them in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Compress this PDF" means two completely different jobs
&lt;/h2&gt;

&lt;p&gt;Users ask for one thing and mean one of two, and the difference decides whether you can help at all.&lt;/p&gt;

&lt;p&gt;If the PDF came from a scanner, there is no text inside it — there's a photograph per page. Ninety-five percent of the weight is images, and compression works spectacularly: re-encoding those images or dropping them from 600 dpi to 200 can take a file down by an order of magnitude, and it stays perfectly readable.&lt;/p&gt;

&lt;p&gt;If the PDF was generated from a word processor, the text weighs almost nothing. The size is in embedded fonts and images, and there's very little to win. A tool promising 80% off that kind of file is rasterising it behind your back — turning every page into a picture. The file gets smaller, and the text stops being selectable, searchable and accessible. Most people don't notice until they try to search the document weeks later.&lt;/p&gt;

&lt;p&gt;Telling the two apart is cheap. Extract the text; if there's essentially none across the pages, you're looking at a scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;looksLikeScan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extractedChars&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;extractedChars&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;pageCount&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Say which one you're dealing with, and what the realistic outcome is. Managing that expectation up front is worth more than any codec.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;pdf-lib&lt;/code&gt; doesn't render, and that surprises people
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pdf-lib&lt;/code&gt; manipulates document structure — pages, metadata, annotations, form fields. It does not draw anything. If you want thumbnails so users can pick pages before splitting, you need &lt;code&gt;pdf.js&lt;/code&gt;, which is a separate library with a separate footprint.&lt;/p&gt;

&lt;p&gt;Shipping both is the normal answer, and it roughly doubles what the user downloads before anything happens. Worth loading the renderer lazily, only when a preview is actually requested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do it client-side at all
&lt;/h2&gt;

&lt;p&gt;The honest summary is that it wins on trust and loses on scale.&lt;/p&gt;

&lt;p&gt;Every "we delete your files after an hour" promise is a statement about what a company does after receiving your document. It might be entirely true. It's still a promise, and for a signed contract or a medical record that's a decision worth making deliberately rather than by default.&lt;/p&gt;

&lt;p&gt;When the work happens in the browser, there's nothing to promise. The request doesn't exist, and anyone can confirm it in ten seconds: open DevTools, Network tab, run the operation, watch nothing leave. That test works on any site, including mine — I run &lt;a href="https://www.pdfonlinefree.com" rel="noopener noreferrer"&gt;pdfonlinefree.com&lt;/a&gt;, which is where all of the above came from.&lt;/p&gt;

&lt;p&gt;What you give up is everything that needs real memory or real CPU. Hundred-page scans, batch jobs, OCR. For those, a desktop application is simply the right tool and I'd say so to anyone who asked.&lt;/p&gt;

&lt;p&gt;The failure modes are the transferable part. Every one of them is silent by default, and every one of them is a place where the easy implementation reports success while handing the user something broken.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>pdf</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The download button that silently did nothing on mobile</title>
      <dc:creator>BellSal</dc:creator>
      <pubDate>Sat, 15 Aug 2026 17:38:15 +0000</pubDate>
      <link>https://dev.to/bellsal_b44bf6d/the-download-button-that-silently-did-nothing-on-mobile-2a3k</link>
      <guid>https://dev.to/bellsal_b44bf6d/the-download-button-that-silently-did-nothing-on-mobile-2a3k</guid>
      <description>&lt;p&gt;For about a year, the save button in my collage editor did nothing on phones. Not "threw an error." Not "downloaded a corrupted file." Nothing at all. You tapped it, the button got its little active state, and then the app just sat there.&lt;/p&gt;

&lt;p&gt;Nobody reported it. That's the part that still bothers me. A button that crashes gets a bug report; a button that does nothing gets interpreted as user error. People assumed they'd tapped wrong, tapped again, then left.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code that looked fine
&lt;/h2&gt;

&lt;p&gt;The editor is vanilla JS drawing to a &lt;code&gt;&amp;lt;canvas&amp;gt;&lt;/code&gt;. Everything happens client-side — the images never leave the device, which is the whole point of the thing. So exporting was the textbook approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;collage.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works in every desktop browser. It works in Chrome DevTools' device emulation, which is how it survived so long. It works in a mobile browser often enough that you don't immediately suspect it.&lt;/p&gt;

&lt;p&gt;It does not work in an Android WebView, and it does not work in iOS Safari.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;download&lt;/code&gt; attribute is a hint, and both of those environments are free to ignore it. An Android WebView has no download manager attached unless the host app wires one up, so the navigation to a &lt;code&gt;data:&lt;/code&gt; URL is simply dropped. iOS Safari has historically refused &lt;code&gt;download&lt;/code&gt; on &lt;code&gt;data:&lt;/code&gt; and &lt;code&gt;blob:&lt;/code&gt; URLs from a synthetic click. Neither throws. Neither logs. There is no rejected promise to catch, no event to listen for. The failure mode is a return value of &lt;code&gt;undefined&lt;/code&gt; from a function that had one job.&lt;/p&gt;

&lt;p&gt;That's what makes this class of bug expensive: you cannot detect it from the JS side. There's no &lt;code&gt;if (downloadWorked)&lt;/code&gt;. You find it by holding a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing Android: stop pretending it's a browser
&lt;/h2&gt;

&lt;p&gt;The mobile apps are the same editor wrapped in Capacitor, so on Android I had a native layer available and used it. The web code hands the base64 payload to a plugin, and the plugin writes the bytes to shared storage with the platform's own file APIs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDataURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Filesystem&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`collage-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Directory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unremarkable, and that's the point. On Android the honest fix was to admit I wasn't in a browser and use the host platform. The user gets a real file in a real folder, which is what they wanted when they tapped save.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing iOS: the Web Share API, and the constraint it doesn't document
&lt;/h2&gt;

&lt;p&gt;iOS has no equivalent escape hatch I was willing to take, so the export goes through the Web Share API instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dataUrlToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;collage.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canShare&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;canShare&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Collage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;canShare({ files })&lt;/code&gt; rather than a bare check for &lt;code&gt;navigator.share&lt;/code&gt;. Sharing text is widely supported; sharing &lt;em&gt;files&lt;/em&gt; is a separate capability, and the feature-detect has to include the payload or it lies to you.&lt;/p&gt;

&lt;p&gt;The part that cost me an evening is not in that snippet. &lt;code&gt;navigator.share()&lt;/code&gt; requires transient user activation — it must be called during the gesture that triggered it. And that activation is consumed by &lt;code&gt;await&lt;/code&gt;. If you write the natural-looking version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// broken on iOS&lt;/span&gt;
&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;blob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;blob&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;collage.png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;  &lt;span class="c1"&gt;// silently dismissed&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...the share sheet either never appears or flashes and dies. Same signature as the original bug: no error, no console output. The &lt;code&gt;await&lt;/code&gt; yielded to the event loop, the activation window closed, and iOS declined without comment.&lt;/p&gt;

&lt;p&gt;So the base64-to-&lt;code&gt;File&lt;/code&gt; conversion has to be &lt;strong&gt;synchronous&lt;/strong&gt;. &lt;code&gt;canvas.toDataURL()&lt;/code&gt; is synchronous, &lt;code&gt;atob()&lt;/code&gt; is synchronous, and the &lt;code&gt;File&lt;/code&gt; constructor is synchronous — the whole chain can run inside the handler with no yield point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dataUrlToFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dataUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mime&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/:&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;.*&lt;/span&gt;&lt;span class="se"&gt;?)&lt;/span&gt;&lt;span class="sr"&gt;;/&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charCodeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;filename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;mime&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;fetch(dataUrl).then(r =&amp;gt; r.blob())&lt;/code&gt; is the prettier version of this function and it is unusable here, because it's async. Same for &lt;code&gt;canvas.toBlob()&lt;/code&gt;. The ugly &lt;code&gt;charCodeAt&lt;/code&gt; loop exists specifically to stay inside the gesture. For a few megapixels it costs a handful of milliseconds, which is a fine trade for a feature that otherwise doesn't exist.&lt;/p&gt;

&lt;p&gt;One caveat worth stating plainly: &lt;code&gt;navigator.share()&lt;/code&gt; returning without rejecting does not mean a file was saved. It means the sheet was presented. You cannot tell whether the user picked "Save Image," sent it to a chat, or cancelled — the promise resolves the same way. Some visibility is simply not available to you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part I'm not happy about
&lt;/h2&gt;

&lt;p&gt;On iOS the user taps a button labelled save and gets a share sheet, where "Save Image" is one option among Messages, Mail, AirDrop and a dozen apps. That is not a download and does not look like one. It's an extra decision at exactly the moment the user thought they were done.&lt;/p&gt;

&lt;p&gt;I shipped it because a share sheet is enormously better than nothing, and because the alternative on iOS was a long-press-to-save instruction nobody reads. But I want to be clear that it's a compromise, not a solution. If you're evaluating this approach: the ceiling is "acceptable," not "good."&lt;/p&gt;

&lt;h2&gt;
  
  
  A second silent failure, same shape
&lt;/h2&gt;

&lt;p&gt;While I was in there, I found a bug with an identical personality in the analytics setup. Google Consent Mode was initialised with defaults denied — correct and required — but the cookie banner that could flip those defaults only rendered for EU visitors, because that's who the compliance requirement targeted.&lt;/p&gt;

&lt;p&gt;Read those two facts together: every visitor outside the EU started denied and was never shown any way to consent. They were permanently invisible in GA4. The site looked close to dead in the reports while it was in fact getting traffic.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;gtag&lt;/code&gt;'s consent defaults take a &lt;code&gt;region&lt;/code&gt; parameter, and the fix is to scope the denial to the places where the banner actually exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;ad_storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;analytics_storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ad_user_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ad_personalization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BG&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CZ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HU&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LV&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LU&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;MT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;IS&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;LI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;CH&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a &lt;code&gt;region&lt;/code&gt; array, the directive applies only to those countries; visitors elsewhere fall through to whatever your unscoped default is. You still need a real consent flow for the listed regions — this isn't a way to opt out of the banner, it's a way to stop applying a banner's defaults where the banner never runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually generalises
&lt;/h2&gt;

&lt;p&gt;Both bugs were invisible to every tool I had pointed at the project. No exception, no failed request, no red line anywhere. They were only visible as an absence: a file that never appeared, a country that never showed up in a report.&lt;/p&gt;

&lt;p&gt;So the lesson I took isn't about &lt;code&gt;download&lt;/code&gt; attributes or consent strings specifically. It's that anything you hand off to the platform — a download, a share sheet, a permission prompt, a consent signal — can be declined in silence, and your code will happily report success. Those handoffs are where I now check the &lt;em&gt;outcome&lt;/em&gt; on a real device rather than trusting the call site.&lt;/p&gt;

&lt;p&gt;The editor in question is &lt;a href="https://freecollageimage.com" rel="noopener noreferrer"&gt;freecollageimage.com&lt;/a&gt;, if the context helps. The bugs are the transferable part.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>ios</category>
      <category>android</category>
    </item>
  </channel>
</rss>
