<?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: nongyi531415</title>
    <description>The latest articles on DEV Community by nongyi531415 (@nongyi531415).</description>
    <link>https://dev.to/nongyi531415</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%2F4120789%2F69a88dd8-d1d3-468e-a18b-b86aeff461ad.png</url>
      <title>DEV Community: nongyi531415</title>
      <link>https://dev.to/nongyi531415</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nongyi531415"/>
    <language>en</language>
    <item>
      <title>Designing a free text preview inside a paid AI video workflow</title>
      <dc:creator>nongyi531415</dc:creator>
      <pubDate>Tue, 27 Oct 2026 05:50:58 +0000</pubDate>
      <link>https://dev.to/nongyi531415/designing-a-free-text-preview-inside-a-paid-ai-video-workflow-1nmh</link>
      <guid>https://dev.to/nongyi531415/designing-a-free-text-preview-inside-a-paid-ai-video-workflow-1nmh</guid>
      <description>&lt;p&gt;A free text preview inside a paid media workflow creates a very specific UI problem: what, exactly, has the user approved?&lt;/p&gt;

&lt;p&gt;I am the maker of &lt;a href="https://airapvideos.app/" rel="noopener noreferrer"&gt;AI Rap Videos&lt;/a&gt;, where a person or pet photo becomes a short rap performance. The public flow lets people preview and edit four English lyric lines before purchasing a video. That gives a concrete example for the design pattern below. This is a suggested frontend model, not a description of our private backend or a claim about conversion results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate three promises
&lt;/h2&gt;

&lt;p&gt;A lyric preview shows the words. It does not preview the final voice, timing, facial motion or complete video. A price quote describes a purchase. A generation result proves that a file exists.&lt;/p&gt;

&lt;p&gt;Those three promises should have different labels and states. A single boolean such as &lt;code&gt;isReady&lt;/code&gt; cannot tell a component which promise it is making.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make stale previews visible
&lt;/h2&gt;

&lt;p&gt;Consider this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A user requests a birthday verse for Alex.&lt;/li&gt;
&lt;li&gt;The user changes the name to Sam before the response arrives.&lt;/li&gt;
&lt;li&gt;The older request returns with Alex's lyrics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If the UI simply renders the last response to finish, it can show an apparently successful preview for the wrong input. Disabling the button alone does not solve requests already in flight.&lt;/p&gt;

&lt;p&gt;A minimal illustrative TypeScript model can attach a local revision to requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;PreviewState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idle&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;loading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;acceptPreview&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;currentRevision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;responseRevision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;PreviewState&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;responseRevision&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;currentRevision&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;revision&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;responseRevision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&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 caller should ignore a null result, not replace the current screen with it. Increment the revision when preview-relevant inputs change. Cancellation can reduce wasted work, but the revision check still matters if cancellation arrives too late. This is a UI consistency mechanism; it is not authorization for a paid job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat editing as ownership
&lt;/h2&gt;

&lt;p&gt;Once a person edits the verse, an automatic refresh should not silently replace it. Keep the editable draft distinct from a proposed replacement, and let the person choose whether to apply the new version. Preserve the draft after a recoverable failure.&lt;/p&gt;

&lt;p&gt;Some settings affect the preview and others affect only the output. Define that boundary explicitly. Changing the subject name should invalidate a verse about the old name; changing portrait to landscape need not discard the person's writing if the text remains applicable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep payment language precise
&lt;/h2&gt;

&lt;p&gt;Use a label such as “Preview my lyrics · free” for the text step, and show the paid video price separately. On our public page, standard 480p and HD 720p also consume different amounts of video balance. A resolution change therefore needs a clear cost update even when the lyrics stay unchanged.&lt;/p&gt;

&lt;p&gt;The server must independently check the final request, balance and applicable rules. A frontend &lt;code&gt;ready&lt;/code&gt; state must never serve as payment proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test the transitions, not just the happy path
&lt;/h2&gt;

&lt;p&gt;Useful checks for this pattern include an old response arriving after a new one, edits made while a request is running, changing a cost-affecting option after preview, and retrying after a network error without losing the draft. Also check that keyboard focus and an accessible status message make loading and errors understandable.&lt;/p&gt;

&lt;p&gt;These checks answer a narrower, testable question than “is the generator working?”: does the UI consistently show the text and price the user actually chose?&lt;/p&gt;

&lt;p&gt;Disclosure: I make the linked product. This article was drafted with AI assistance and checked against the current public product flow; the code is an illustrative pattern, not production source.&lt;/p&gt;

</description>
      <category>design</category>
      <category>frontend</category>
      <category>product</category>
      <category>ui</category>
    </item>
    <item>
      <title>Building a pet-only meme video page: what the sample needs to prove</title>
      <dc:creator>nongyi531415</dc:creator>
      <pubDate>Sun, 11 Oct 2026 14:28:23 +0000</pubDate>
      <link>https://dev.to/nongyi531415/building-a-pet-only-meme-video-page-what-the-sample-needs-to-prove-2die</link>
      <guid>https://dev.to/nongyi531415/building-a-pet-only-meme-video-page-what-the-sample-needs-to-prove-2die</guid>
      <description>&lt;p&gt;I built &lt;a href="https://bulin47.xyz/" rel="noopener noreferrer"&gt;Bulin47&lt;/a&gt;, a pet-only video project inspired by the Bulin 47 freestyle meme. The idea is deliberately narrow: use one authorized pet photo to replace the lead performer in a fixed clip, keeping the other people and the scene unchanged.&lt;/p&gt;

&lt;p&gt;The output specification is about 19 seconds, vertical 9:16, 480p, with the original freestyle audio. The frontend uses TanStack Start, React and TypeScript, with Cloudflare Workers for deployment. The video-editing integration uses Seedance 2.5 through EvoLink.&lt;/p&gt;

&lt;h2&gt;
  
  
  A comparison is more useful than a polished thumbnail
&lt;/h2&gt;

&lt;p&gt;A thumbnail can show the pet but hide whether the motion, timing and surrounding scene survived the edit. I put an original-versus-pet comparison alongside the input photo on the public page. That gives visitors three things to inspect: the source identity, the replaced performer, and the unchanged context.&lt;/p&gt;

&lt;p&gt;For this kind of app, I would evaluate a sample along separate axes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the pet remain recognizable throughout the clip?&lt;/li&gt;
&lt;li&gt;Does the lead follow the reference movement and timing?&lt;/li&gt;
&lt;li&gt;Do background people and objects stay consistent?&lt;/li&gt;
&lt;li&gt;Does the exported file preserve the expected duration, orientation and audio?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One attractive frame cannot answer all four. A full clip and a visible input make the example easier to judge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the input boundary explicit
&lt;/h2&gt;

&lt;p&gt;This project accepts pet photos only, without human faces or bodies. It is a useful constraint to communicate directly in the upload instructions instead of leaving it inside a policy page. The goal is pet entertainment, with a single fixed reference rather than an unrestricted face-swap interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the example match the copy
&lt;/h2&gt;

&lt;p&gt;I also checked that the deployed sample assets matched the reviewed video files. A page can have correct copy while still serving an older sample. Keeping the input photo, comparison video and description aligned matters just as much as the framework choice.&lt;/p&gt;

&lt;p&gt;You can inspect the &lt;a href="https://bulin47.xyz/" rel="noopener noreferrer"&gt;Bulin47 sample comparison&lt;/a&gt;. I would welcome feedback on whether the comparison makes the intended edit clear.&lt;/p&gt;

&lt;p&gt;This is an independent fan-made project, not affiliated with the artists or their labels. Results vary; the example is not a quality guarantee for every pet photo.&lt;/p&gt;

</description>
      <category>frontend</category>
      <category>react</category>
      <category>showdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I built an AI pet dance app: one photo, one generator</title>
      <dc:creator>nongyi531415</dc:creator>
      <pubDate>Sat, 10 Oct 2026 13:25:30 +0000</pubDate>
      <link>https://dev.to/nongyi531415/i-built-an-ai-pet-dance-app-one-photo-one-generator-138m</link>
      <guid>https://dev.to/nongyi531415/i-built-an-ai-pet-dance-app-one-photo-one-generator-138m</guid>
      <description>&lt;p&gt;I built &lt;a href="https://catdance.app/" rel="noopener noreferrer"&gt;CatDance&lt;/a&gt;, a small web app that turns a pet photo into an AI dance video. The idea is playful: take a cat, dog or plush photo and make it the star of a short dance clip.&lt;/p&gt;

&lt;p&gt;Update — October 23, 2026: CatDance now offers a &lt;a href="https://catdance.app/templates/neon" rel="noopener noreferrer"&gt;NEON cat dance template&lt;/a&gt; alongside Smalltown Boy. Personalized generation is paid; the earlier free-preview offer described at launch has ended.&lt;/p&gt;

&lt;h2&gt;
  
  
  One photo, two dance templates
&lt;/h2&gt;

&lt;p&gt;For &lt;a href="https://catdance.app/templates/neon" rel="noopener noreferrer"&gt;NEON&lt;/a&gt;, upload a clear cat photo to replace the lead dancer in the routine to A2O LITE’s NEON. Add a second pet photo, reuse the same photo for both dancers, or leave B empty to keep the default tuxedo cat. The scene is fixed. New videos are approximately 10 seconds, 720p and landscape 16:9, with original template audio and no watermark. The white-cat demo is labeled 480p; it is not a paid 720p output example.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://catdance.app/smalltown-boy" rel="noopener noreferrer"&gt;Smalltown Boy&lt;/a&gt; remains a separate template: about 19 seconds, 720p and portrait 9:16, with an optional second pet and preset backgrounds. Its background choices do not apply to NEON.&lt;/p&gt;

&lt;p&gt;Both use one-time USD purchases: $9.90 for one generation or $29.90 for four. There is no subscription or free personalized generation. Confirmed failed generations restore the credit; completed AI results can vary in appearance and movement. Including original template audio does not grant additional music rights or a commercial-use license.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generation is only half the job
&lt;/h2&gt;

&lt;p&gt;The frontend uses React and TanStack Start, with the application running on Cloudflare Workers. Generated files are delivered through object storage.&lt;/p&gt;

&lt;p&gt;One practical lesson: a provider reporting “completed” is not the same as a customer having a downloadable video. The application still has to retrieve the output, assemble the full clip when necessary, attach template audio and persist the final file.&lt;/p&gt;

&lt;p&gt;I now treat delivery as an explicit part of the job lifecycle. Errors record which stage failed, retries have a limit, and failed orders return the generation credit. An ambiguous submission should also be investigated before automatically submitting the same paid model request again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show the output before asking for a purchase
&lt;/h2&gt;

&lt;p&gt;The homepage includes real input-photo and video examples. For a creative tool, those pairs explain the product better than a long feature list: people can judge whether the result is something they would want to share.&lt;/p&gt;

&lt;p&gt;Photo quality and subject identity still matter. A clear image with one visible subject is a better starting point than a crowded or heavily cropped photo. Two-pet replacement is a more demanding task than replacing a single pet, and AI results can vary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;You can see the examples and use the generator at &lt;strong&gt;&lt;a href="https://catdance.app/" rel="noopener noreferrer"&gt;catdance.app&lt;/a&gt;&lt;/strong&gt;. I am the maker of the product; this is a launch note, not an independent review.&lt;/p&gt;

&lt;p&gt;For the new routine, start with the &lt;a href="https://catdance.app/templates/neon" rel="noopener noreferrer"&gt;NEON examples and generator&lt;/a&gt;. I would be interested in feedback on whether the template examples and upload flow make the choices clear.&lt;/p&gt;

</description>
      <category>showdev</category>
    </item>
    <item>
      <title>Why nuke simulators show different numbers (and how to compare them)</title>
      <dc:creator>nongyi531415</dc:creator>
      <pubDate>Fri, 02 Oct 2026 14:37:02 +0000</pubDate>
      <link>https://dev.to/nongyi531415/why-nuke-simulators-show-different-numbers-and-how-to-compare-them-5a7h</link>
      <guid>https://dev.to/nongyi531415/why-nuke-simulators-show-different-numbers-and-how-to-compare-them-5a7h</guid>
      <description>&lt;p&gt;After I posted a short video of a simulated 15 kiloton airburst, a viewer commented that they had checked another calculator and got different numbers. That is a fair point, and the answer is not that one tool is broken. Blast simulators make different modelling choices, and those choices move the rings a lot.&lt;/p&gt;

&lt;p&gt;I build &lt;a href="https://kaboomly.app/" rel="noopener noreferrer"&gt;Kaboomly&lt;/a&gt;, a free nuke map that runs in the browser, so here are the four choices that matter most.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Airburst or surface burst
&lt;/h2&gt;

&lt;p&gt;The same weapon behaves very differently depending on where it goes off. An airburst spreads the blast wave over a wider area, so the light-damage rings get bigger. A surface burst gives smaller blast rings but pulls soil into the fireball and creates local fallout. Some tools default to one, some to the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Burst height
&lt;/h2&gt;

&lt;p&gt;Even among airbursts, height matters. There is a height that maximizes the area of a given overpressure, and it is different for every pressure level. Kaboomly does not optimize this. It takes one documented historical example, Hiroshima at about 1,670 ft for roughly 12.5 kt, and scales it with the cube root of the yield. A tool that picks a different height will show different rings for the same yield.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Which threshold a ring represents
&lt;/h2&gt;

&lt;p&gt;A ring labelled "heavy damage" might be drawn at 5 psi in one tool and at 10 or 20 psi in another. Thermal rings can mean first, second or third-degree burns, and the distance changes a lot between them. Always check the legend before comparing two maps.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Scaling law and source data
&lt;/h2&gt;

&lt;p&gt;Most public calculators, including NUKEMAP and Kaboomly, trace back to &lt;em&gt;The Effects of Nuclear Weapons&lt;/em&gt; by Glasstone and Dolan (1977). Blast distances scale with the cube root of yield, so ten times the yield gives only about 2.15 times the distance. The fireball has its own scaling, about W^0.4. Tools that use rounded rules of thumb instead of the published curves will drift, especially at very small or very large yields.&lt;/p&gt;

&lt;h2&gt;
  
  
  An example
&lt;/h2&gt;

&lt;p&gt;For a 15 kt airburst Kaboomly shows the 5 psi ring at about 1.6 km (1.0 mi) and the 1 psi ring at about 4.5 km (2.8 mi). If another tool uses a surface burst or a different height, its numbers for the same yield can be noticeably smaller or larger, and both can be correct for their own assumptions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did about it
&lt;/h2&gt;

&lt;p&gt;On Kaboomly every result shows the burst type and the thresholds, and there is a page explaining &lt;a href="https://kaboomly.app/how-it-works/" rel="noopener noreferrer"&gt;how the numbers are calculated&lt;/a&gt; with references. The site never shows casualty estimates, only the rings and the number of residents living inside an area.&lt;/p&gt;

&lt;p&gt;If you compare simulators, compare like with like: same yield, same burst type, same threshold. The differences mostly disappear.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>maps</category>
      <category>history</category>
    </item>
    <item>
      <title>Splitting a large image across multiple printed pages without alignment marks</title>
      <dc:creator>nongyi531415</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:46:00 +0000</pubDate>
      <link>https://dev.to/nongyi531415/splitting-a-large-image-across-multiple-printed-pages-without-alignment-marks-115c</link>
      <guid>https://dev.to/nongyi531415/splitting-a-large-image-across-multiple-printed-pages-without-alignment-marks-115c</guid>
      <description>&lt;p&gt;I recently added tiled printing to a small static tool I run (&lt;a href="https://griddrawing.org" rel="noopener noreferrer"&gt;ArtGrid&lt;/a&gt;) — the kind of "print a big image across several A4 sheets and tape them together" feature you've probably seen in poster-printing tools. The naive version is easy: slice the enlarged image into a grid of page-sized rectangles, export each as a PDF page. The annoying version is making the pieces line back up on paper, where a fraction of a millimeter of printer scaling error compounds across every seam.&lt;/p&gt;

&lt;p&gt;Most tools solve this with &lt;strong&gt;alignment marks&lt;/strong&gt; — corner crosses or registration dots you have to trim to and match by eye. I skipped that entirely by changing where the cuts land.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: cut on lines that are already there
&lt;/h2&gt;

&lt;p&gt;My tool overlays a grid on the reference image — rows and columns of equal square cells, used for the classic grid-method drawing technique. Once you're enlarging that grid to poster size, you already have a full set of straight lines running across the whole surface. If every page boundary is forced to land exactly on one of those grid lines, the seam &lt;em&gt;is&lt;/em&gt; the line. There's nothing to trim, because the join is a continuation of a mark you were going to draw anyway.&lt;/p&gt;

&lt;p&gt;The constraint this puts on the math: instead of dividing by fixed page dimensions, you divide by &lt;strong&gt;however many whole cells fit on a page&lt;/strong&gt;, and let the last row/column of pages be a partial page if it doesn't divide evenly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;tileGeometry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GridGeometry&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;TileResult&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;area&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tileArea&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;paper&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// printable area minus margins&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;drawingCell&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;geometry&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;drawingCell&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;drawingCell&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`cell &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;drawingCell&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;mm exceeds printable area`&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;colsPerPage&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;drawingCell&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;rowsPerPage&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;area&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;drawingCell&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;pagesX&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;colsPerPage&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;pagesY&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;rowsPerPage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;colsPerPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rowsPerPage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pagesX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pagesY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pageCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pagesX&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pagesY&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;Each page then covers a &lt;code&gt;[colStart, colEnd) × [rowStart, rowEnd)&lt;/code&gt; slice of cells, and the pixel crop for that page is just that slice's bounding box in the source image, scaled the same way the whole grid was scaled. Cutting on &lt;code&gt;floor(printable_area / cell_size)&lt;/code&gt; rather than on the raw page dimension is the entire trick — it's a one-line change from "divide by page size" that removes a whole category of physical-alignment UX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this only works if the cells are square
&lt;/h2&gt;

&lt;p&gt;This only holds together because every cell is guaranteed to be the same width and height. If rows and columns are sized independently — which most grid tools do, because it lets you fill the page edge-to-edge — a "cell" isn't a fixed unit you can tile against, and you're back to needing marks. Forcing square cells (deriving whichever axis you didn't set, rather than letting both float to fill the sheet) was originally a decision about drawing accuracy — a square cell's diagonal is a clean 45°, useful for judging angles by eye — but it turned out to be the same property that makes tiling trivial later. One geometric constraint paid for two unrelated features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the seams
&lt;/h2&gt;

&lt;p&gt;The part I actually didn't trust until I checked it: does the printable rectangle reconstructed from all the tiles equal the original enlarged rectangle, to the millimeter? I wrote this as a standalone assertion rather than trusting the math by inspection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;topRowWidth&lt;/span&gt; &lt;span class="o"&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;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rowStart&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&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;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tileRectMm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert&lt;/span&gt;&lt;span class="p"&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;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topRowWidth&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;drawingRect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.01&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— summed the top row's page widths, summed the left column's page heights, compared both against the target enlargement size. It matched to a hundredth of a millimeter, which is the point where you stop worrying about floating point and start worrying about your actual printer's calibration instead (which is why there's still a 50mm ruler printed on the first page — measure it, and if your printer scaled the page, you'll catch it before taping together nine sheets).&lt;/p&gt;

&lt;p&gt;If you want to see the live version: &lt;a href="https://griddrawing.org/print-large-image-multiple-pages" rel="noopener noreferrer"&gt;griddrawing.org/print-large-image-multiple-pages&lt;/a&gt;. It's a static site, runs entirely client-side, source reference stays in the browser tab.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>canvas</category>
      <category>math</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
