<?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: jake</title>
    <description>The latest articles on DEV Community by jake (@jakexkim).</description>
    <link>https://dev.to/jakexkim</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%2F3838932%2F7985cd45-39fb-42e3-bfc4-8acbf05226d2.png</url>
      <title>DEV Community: jake</title>
      <link>https://dev.to/jakexkim</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jakexkim"/>
    <language>en</language>
    <item>
      <title>What Is Visual Debt (and Why Your Docs Are Lying to Users)</title>
      <dc:creator>jake</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:30:50 +0000</pubDate>
      <link>https://dev.to/jakexkim/what-is-visual-debt-and-why-your-docs-are-lying-to-users-cjg</link>
      <guid>https://dev.to/jakexkim/what-is-visual-debt-and-why-your-docs-are-lying-to-users-cjg</guid>
      <description>&lt;p&gt;&lt;strong&gt;Visual debt&lt;/strong&gt; is the accumulated gap between a product's current interface and the screenshots in its documentation. It compounds with every UI change that is not reflected in docs, eroding user trust and generating support burden. Unlike technical debt, visual debt is experienced directly by end users — and most teams have no mechanism to pay it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The definition: visual debt in one sentence
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Visual debt&lt;/strong&gt; is the difference between what your product looks like right now and what your documentation shows. Every screenshot that depicts a button, layout, or workflow that no longer matches the live product adds to this balance. The debt accrues interest in the form of confused users, misguided support tickets, and lost trust in your documentation as a reliable source of truth.&lt;/p&gt;

&lt;p&gt;The term is modeled deliberately on &lt;strong&gt;technical debt&lt;/strong&gt;, which Ward Cunningham coined at the OOPSLA 1992 conference to describe the cost of shipping code that reflected an incomplete understanding of the problem domain. Cunningham's insight was that expedient choices in code create a compounding cost — and the longer you wait to address them, the more expensive the fix becomes. Visual debt operates on the same principle, but the interest is paid by your users, not your developers.&lt;/p&gt;

&lt;p&gt;I started using this term after auditing documentation for several SaaS products and finding that the visual state of the docs consistently lagged the product by &lt;strong&gt;two to four releases&lt;/strong&gt;. The text was updated, the API references were current, but the screenshots showed UIs that no longer existed. Every time a user followed a screenshot to a button that had moved or been renamed, that was visual debt collecting interest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The analogy to technical debt is intentional
&lt;/h2&gt;

&lt;p&gt;The power of Cunningham's original technical debt metaphor was that it gave engineering and business teams a shared language. The reason "technical debt" works as a concept is that it reframes an engineering problem in financial terms that executives understand.&lt;/p&gt;

&lt;p&gt;Visual debt borrows this framing because the same communication gap exists around documentation quality. When an engineer says "our screenshots are outdated," the response is typically a shrug — it is not perceived as urgent. When they say "we have 80 hours of visual debt and it is generating 15% of our support tickets," the conversation changes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Technical debt&lt;/th&gt;
&lt;th&gt;Visual debt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Coined&lt;/td&gt;
&lt;td&gt;Ward Cunningham, OOPSLA 1992&lt;/td&gt;
&lt;td&gt;Introduced here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Definition&lt;/td&gt;
&lt;td&gt;Cost of expedient code choices&lt;/td&gt;
&lt;td&gt;Gap between current UI and docs screenshots&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who pays interest&lt;/td&gt;
&lt;td&gt;Developers (slower feature velocity)&lt;/td&gt;
&lt;td&gt;End users (confusion, failed tasks)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compounds via&lt;/td&gt;
&lt;td&gt;Code complexity over time&lt;/td&gt;
&lt;td&gt;UI changes per release cycle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Detected by&lt;/td&gt;
&lt;td&gt;Linters, code reviews, static analysis&lt;/td&gt;
&lt;td&gt;Nothing (manual audit only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid down by&lt;/td&gt;
&lt;td&gt;Refactoring, rewriting&lt;/td&gt;
&lt;td&gt;Re-capturing screenshots (manual or automated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tracking tools&lt;/td&gt;
&lt;td&gt;SonarQube, CodeClimate, Snyk&lt;/td&gt;
&lt;td&gt;None widely adopted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The critical row in that table is "Detected by." Technical debt, for all its challenges, has an ecosystem of tools that surface it. Visual debt has nothing. No linter flags a three-release-old PNG. No CI check compares a screenshot against the live UI. This detection gap is why visual debt accumulates silently until a user files a support ticket saying "your docs show a settings page that does not exist."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the conventional advice is wrong
&lt;/h2&gt;

&lt;p&gt;The standard technical writing guidance on documentation screenshots falls into two camps, both wrong:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"Use fewer screenshots"&lt;/strong&gt; — This is surrender, not strategy. Screenshots are the single most effective way to orient a user in a UI. Removing them to avoid maintenance cost means your documentation is less useful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Keep screenshots updated"&lt;/strong&gt; — This is advice with no mechanism. It is equivalent to saying "have fewer bugs." Without a system that automatically captures and refreshes screenshots, telling teams to keep them updated produces nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason both camps of advice fail is that they treat visual debt as a discipline problem when it is actually an infrastructure problem. Teams do not fail to update screenshots because they are lazy — they fail because the process is manual, unowned, and disconnected from the release cycle. Camunda's engineering team reported that their documentation user guide contained 94 screenshots, and that manually recreating all of them for a single release would take one to two full days. Their solution was to automate capture through end-to-end tests — they treated it as infrastructure, not process.&lt;/p&gt;

&lt;h2&gt;
  
  
  How visual debt compounds
&lt;/h2&gt;

&lt;p&gt;Visual debt does not grow linearly — it compounds. Each release introduces UI changes. Some are cosmetic (a button color change, an icon swap). Some are structural (a page reorganization, a feature rename). Every change that touches a UI element visible in a screenshot creates visual debt. But unlike code changes, where a failing test immediately surfaces the problem, screenshot staleness triggers &lt;strong&gt;no automated alert&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The compounding effect works across three dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Breadth&lt;/strong&gt; — a single header redesign can invalidate dozens of screenshots simultaneously. When Camunda changed their header, every screenshot containing a header was instantly stale. With 94 screenshots, even a minor navigation change creates a cascading update burden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth&lt;/strong&gt; — screenshots accumulate over time. A product that ships a release every two weeks with five new features will add 5–15 new screenshots per quarter. If none are being retired, the maintenance surface area grows every sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Velocity&lt;/strong&gt; — faster release cycles mean faster visual debt accumulation. A product releasing biweekly at a Series B/C stage can realistically invalidate &lt;strong&gt;10–20% of its documentation screenshots per quarter&lt;/strong&gt; through normal UI iteration. Without automated recapture, the debt balance grows monotonically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a typical scenario — a product releasing every two weeks with 8–12% of screenshots affected per release and no automated recapture — &lt;strong&gt;over half of documentation screenshots are visually inaccurate within five releases&lt;/strong&gt;. That is roughly two and a half months. At that point, screenshots are not documentation — they are disinformation.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure your visual debt balance
&lt;/h2&gt;

&lt;p&gt;Visual debt can be quantified. Here is the formula I use:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Visual debt balance (hours)&lt;/strong&gt; = total screenshots × staleness rate × minutes per manual update ÷ 60&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Total screenshots&lt;/strong&gt; — count every screenshot in your docs, including variants (mobile, dark mode). A typical B2B SaaS product has 50–200.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staleness rate&lt;/strong&gt; — the percentage of screenshots that no longer accurately reflect the current UI. Audit manually or estimate based on releases since last docs refresh. If you have not updated screenshots in three releases, assume 25–40%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Minutes per manual update&lt;/strong&gt; — time to navigate to the right state, capture, crop, annotate, export, commit, and deploy. Manual capture typically takes &lt;strong&gt;10–15 minutes&lt;/strong&gt; per screenshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A worked example: a product with &lt;strong&gt;120 screenshots&lt;/strong&gt;, of which &lt;strong&gt;35%&lt;/strong&gt; are stale (42 screenshots), each requiring &lt;strong&gt;12 minutes&lt;/strong&gt; of manual effort to update. The visual debt balance is 42 × 12 ÷ 60 = &lt;strong&gt;8.4 engineering hours&lt;/strong&gt;. At a fully loaded rate of $75/hour, that is &lt;strong&gt;$630&lt;/strong&gt; of accumulated visual debt. Multiply by 4 quarterly releases, and you are looking at $2,520/year of recurring manual cost — assuming you actually do the work. Most teams do not, and the debt simply grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only way to pay it down
&lt;/h2&gt;

&lt;p&gt;Manual screenshot maintenance is the documentation equivalent of manual deployments — it works at small scale and fails structurally as the product grows. The only sustainable way to reach and maintain a visual debt balance of zero is to automate documentation screenshots through CI.&lt;/p&gt;

&lt;p&gt;The mechanism has three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declarative screenshot definitions&lt;/strong&gt; — define what to capture (URL, selector, viewport, wait condition) in a config file that lives in your repo alongside documentation source files. The config is the single source of truth, not a scattered set of PNGs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline-triggered capture&lt;/strong&gt; — wire the capture step into your existing CI pipeline so it runs on every deploy, every PR to main, or on a schedule. This is what Camunda did with their end-to-end test suite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stable delivery URLs&lt;/strong&gt; — serve screenshots through CDN URLs that always resolve to the latest capture. This decouples screenshot freshness from docs deployment — the documentation page is never redeployed, but its images are always current. This is the step that separates a zero-visual-debt architecture from one that merely reduces the manual effort.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The outcome: visual debt drops to zero and stays there. Every &lt;code&gt;git push&lt;/code&gt; to main triggers a recapture. Every docs page serves the latest screenshot. No human intervention required.&lt;/p&gt;

&lt;p&gt;For the full picture of what visual debt costs and how to architect around it, see &lt;a href="https://dev.to/visual-debt"&gt;what visual debt actually costs your team&lt;/a&gt; and &lt;a href="https://dev.to/blog/screenshots-as-code"&gt;screenshots as code&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is visual debt?&lt;/strong&gt;&lt;br&gt;
Visual debt is the accumulated difference between a product's current user interface and the screenshots shown in its documentation. Every UI change that is not reflected in docs screenshots increases the visual debt balance. Unlike text-based documentation errors, visual debt is immediately apparent to users — a screenshot showing a button that no longer exists cannot be skimmed past.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How is visual debt different from technical debt?&lt;/strong&gt;&lt;br&gt;
Technical debt, coined by Ward Cunningham in 1992, describes the cost of expedient code choices. Visual debt describes the cost of expedient documentation choices. The key difference is who experiences the interest: technical debt slows down developers, while visual debt directly confuses end users, generating support tickets and eroding trust in the product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you measure visual debt?&lt;/strong&gt;&lt;br&gt;
Count the total screenshots in your documentation, multiply by the percentage that no longer accurately reflect the current UI, and multiply by the average time to manually update each one. The result is your visual debt balance expressed in engineering hours. A product with 100 screenshots where 40% are stale and each takes 12 minutes to update carries 8 hours of visual debt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do teams accumulate visual debt?&lt;/strong&gt;&lt;br&gt;
Visual debt accumulates because screenshot maintenance is entirely manual in most workflows. No CI/CD pipeline catches a stale screenshot. No linter flags a PNG that was last modified three releases ago. The task of updating screenshots has no natural owner — developers consider it a docs problem, and technical writers consider it a product problem. Without tooling that automates the capture-and-update cycle, visual debt is structurally inevitable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you pay down visual debt?&lt;/strong&gt;&lt;br&gt;
The only sustainable way to pay down visual debt is to automate screenshot capture through CI/CD pipelines. Define screenshot targets in a config file, trigger capture on every deploy, and serve images through stable CDN URLs that always resolve to the latest version. This eliminates the manual cycle that causes visual debt to accumulate in the first place.&lt;/p&gt;

</description>
      <category>screenshots</category>
      <category>automation</category>
      <category>documentation</category>
      <category>ci</category>
    </item>
    <item>
      <title>Screenshots as Code for Docs</title>
      <dc:creator>jake</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:30:48 +0000</pubDate>
      <link>https://dev.to/jakexkim/screenshots-as-code-for-docs-4hfl</link>
      <guid>https://dev.to/jakexkim/screenshots-as-code-for-docs-4hfl</guid>
      <description>&lt;h1&gt;
  
  
  Screenshots as Code: Automating Documentation Visuals
&lt;/h1&gt;

&lt;p&gt;In 2016, the idea of defining your server infrastructure in a YAML file and committing it to git felt radical. Today, infrastructure as code is the default. Nobody manually configures production servers through a web console anymore.&lt;/p&gt;

&lt;p&gt;Documentation screenshots are stuck in the 2015 era. Someone opens the product, manually navigates to the right screen, takes a screenshot, crops it, annotates it, and drops it into a docs folder. When the UI changes, someone (maybe the same person, maybe not) has to repeat the entire process. There is no version control, no automation, and no way to know whether the screenshots in your docs still match reality.&lt;/p&gt;

&lt;p&gt;The screenshots-as-code approach applies the same principles that transformed infrastructure management to documentation visuals. Define what you want to capture in configuration. Let automation handle the execution. Integrate it into your existing workflows. Version everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Infrastructure as Code Analogy
&lt;/h2&gt;

&lt;p&gt;The parallels are direct:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Infrastructure as Code&lt;/th&gt;
&lt;th&gt;Screenshots as Code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Terraform/Pulumi config files&lt;/td&gt;
&lt;td&gt;Screenshot capture config files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;terraform plan&lt;/code&gt; (preview changes)&lt;/td&gt;
&lt;td&gt;Visual diff (preview screenshot changes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;terraform apply&lt;/code&gt; (deploy infra)&lt;/td&gt;
&lt;td&gt;Capture and publish (deploy screenshots)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;State file (current infra state)&lt;/td&gt;
&lt;td&gt;Visual registry (current screenshot state)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drift detection (config vs. actual)&lt;/td&gt;
&lt;td&gt;Visual debt detection (screenshot vs. live UI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-environment (staging, prod)&lt;/td&gt;
&lt;td&gt;Multi-variant (themes, locales, roles)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The mental model is the same: your documentation visuals should be a &lt;strong&gt;deterministic output&lt;/strong&gt; of a declarative configuration, not a manual artifact managed through tribal knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config-Driven Screenshot Capture
&lt;/h2&gt;

&lt;p&gt;The foundation of screenshots-as-code is a configuration file that declaratively defines every screenshot your documentation needs. Here is what that looks like in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:3000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"storageStatePath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".reshot/auth-state.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"viewport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"width"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"height"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scenarios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dashboard-overview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dashboard overview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Main dashboard with sample data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/dashboard"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"readySelector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[data-ready='true']"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"waitForSelector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".dashboard-container"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screenshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dashboard-overview"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"settings-notifications"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Settings notifications tab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Settings page with notifications tab active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/settings/general"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[data-tab='notifications']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screenshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"settings-notifications"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"project-create-modal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"New project modal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"New project creation modal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/projects"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[data-action='new-project']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"waitForSelector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".modal-overlay"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screenshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"project-create-modal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each scenario entry defines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where&lt;/strong&gt; to navigate (&lt;code&gt;url&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How&lt;/strong&gt; to get there (&lt;code&gt;steps&lt;/code&gt; -- &lt;code&gt;goto&lt;/code&gt;, &lt;code&gt;click&lt;/code&gt;, &lt;code&gt;wait&lt;/code&gt;, &lt;code&gt;waitForSelector&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; needed to reach the target state)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What&lt;/strong&gt; to capture (the &lt;code&gt;screenshot&lt;/code&gt; step and its stable &lt;code&gt;key&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why&lt;/strong&gt; it exists (&lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configuration is the source of truth. If a screenshot is not in the config, it should not be in your docs. If a step references a selector that no longer exists, the capture fails and you get an immediate signal that something changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps: Reaching Complex States
&lt;/h3&gt;

&lt;p&gt;Real documentation screenshots are rarely just "navigate to a URL and take a picture." You need to open modals, switch tabs, fill in sample data, expand accordions, hover over tooltips. The &lt;code&gt;steps&lt;/code&gt; array handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"billing-upgrade-flow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Billing upgrade flow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/settings/billing"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Upgrade modal with Pro plan selected and coupon applied"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[data-action='upgrade']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"waitForSelector"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".plan-comparison"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"click"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[data-plan='pro']"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wait"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"selector"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#coupon-code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DEMO2026"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"screenshot"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"billing-upgrade-flow"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is reproducible. Any engineer can read this config and understand exactly what the screenshot should show. There is no ambiguity, no "I think Sarah took this screenshot last quarter."&lt;/p&gt;

&lt;h2&gt;
  
  
  Variant Management
&lt;/h2&gt;

&lt;p&gt;This is where screenshots-as-code delivers its biggest advantage over manual processes. Modern products have multiple visual states, and your documentation should reflect all of them.&lt;/p&gt;

&lt;p&gt;Variants are declared once under a top-level &lt;code&gt;variants.dimensions&lt;/code&gt; block, then opted into per scenario with &lt;code&gt;"variants": { "dimensions": ["theme", "locale"] }&lt;/code&gt;. Each option lists how to set the state with an &lt;code&gt;inject&lt;/code&gt; array (&lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;cookie&lt;/code&gt;, or &lt;code&gt;browser&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Theme Variants
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"theme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Theme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"light"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Light Mode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"localStorage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app-theme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"light"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"browser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"colorScheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"light"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"dark"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dark Mode"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"localStorage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"app-theme"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dark"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
              &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"browser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"colorScheme"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dark"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Locale Variants
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Locale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"English"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"ja"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Japanese"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ja"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"de"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"German"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"locale"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"de"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Role Variants
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"variants"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dimensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"admin"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Admin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"localStorage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth-token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${ADMIN_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"member"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Member"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"localStorage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth-token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${MEMBER_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"viewer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Viewer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"inject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"localStorage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"auth-token"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"${VIEWER_TOKEN}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When dimensions are opted into on a scenario, a single scenario definition produces multiple output images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docs/images/
  dashboard-overview/
    light-en-admin.png
    light-en-member.png
    light-ja-admin.png
    dark-en-admin.png
    dark-en-member.png
    ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A documentation set with 50 scenarios and three variant dimensions (2 themes, 3 locales, 3 roles) produces &lt;strong&gt;900 screenshots&lt;/strong&gt; from 50 scenario entries. Doing this manually is not just tedious -- it is practically impossible to maintain. With config-driven capture, adding a new locale means adding one entry to the variants block and re-running automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local Automation
&lt;/h2&gt;

&lt;p&gt;Screenshots-as-code reaches its full potential when integrated into your local workflow or any automation system. Here is a practical example using a shell script or build tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# Visual Documentation Sync&lt;/span&gt;

&lt;span class="c"&gt;# Run this script on code changes or on a schedule (e.g., weekly)&lt;/span&gt;

npm run build
npm start &amp;amp;
&lt;span class="nv"&gt;APP_PID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;
npx wait-on http://localhost:3000 &lt;span class="nt"&gt;--timeout&lt;/span&gt; 60000

&lt;span class="c"&gt;# Capture against localhost, diff against the live registry, and push any&lt;/span&gt;
&lt;span class="c"&gt;# changed captures to the Reshot review queue. One command, no diff plumbing.&lt;/span&gt;
npx @reshotdev/screenshot run &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--config&lt;/span&gt; reshot.config.json

&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="nv"&gt;$APP_PID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can integrate this into your build system, run it locally before commits, or script it into any workflow you prefer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What This Automation Does
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Triggers on UI changes.&lt;/strong&gt; Re-capture screenshots whenever frontend code changes. No manual intervention needed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Runs on a schedule.&lt;/strong&gt; Set up a cron job or scheduled task to run weekly and catch changes that might slip through -- data-driven UI changes, third-party widget updates, or content changes from a CMS.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Diffs against the live registry.&lt;/strong&gt; &lt;code&gt;reshot run&lt;/code&gt; compares each freshly captured screenshot against the current approved version Reshot is already serving. Anti-aliasing and rendering variance are tuned out, so you only hear about real changes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sends changed captures to Review.&lt;/strong&gt; Changed screenshots land in the Reshot review queue as candidates, with the old version, the new version, and the visual diff side-by-side -- not as a pile of binary files in a git PR for someone to eyeball.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Review is the checkpoint. A reviewer approves a candidate and the &lt;strong&gt;same durable URL&lt;/strong&gt; Reshot was already serving flips to the new image -- every doc, README, and embed updates at once, with no find-and-replace and no broken links.&lt;/p&gt;

&lt;h3&gt;
  
  
  Integration with Existing Workflows
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;The goal is not to add a new process. It is to fold documentation visuals into the process you already have.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Most teams already have automation for testing, linting, and deployment. Screenshots-as-code adds one more step to that automation. It uses the same infrastructure, the same review process, and the same deployment cadence. No new tools to learn, no new workflows to adopt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freshness Boundaries
&lt;/h2&gt;

&lt;p&gt;One of the most powerful aspects of the automation approach is that you get &lt;strong&gt;bounded freshness&lt;/strong&gt;. If your automation runs on every merge to main, your screenshots are never more than one merge behind the current localhost build you captured. If it runs weekly, your maximum drift is seven days.&lt;/p&gt;

&lt;p&gt;Compare this to manual processes where screenshots can drift for months or years without anyone noticing.&lt;/p&gt;

&lt;p&gt;You can enforce freshness at different levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard gate&lt;/strong&gt;: Fail the build if any screenshot diffs exceed a threshold. This is aggressive but guarantees zero visual debt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Soft gate&lt;/strong&gt;: Create a PR but do not block the build. This is more practical for most teams and keeps visual updates in the review queue without slowing down feature development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring only&lt;/strong&gt;: Capture diffs and report them to a dashboard without any blocking. Useful as a first step when introducing screenshots-as-code to a team.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where This Approach Came From
&lt;/h2&gt;

&lt;p&gt;The idea of treating screenshots as code did not emerge in a vacuum. It follows a well-established pattern in software engineering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2013-2015&lt;/strong&gt;: Infrastructure as code (Terraform, CloudFormation) replaces manual server configuration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2016-2018&lt;/strong&gt;: Configuration as code (Kubernetes manifests, Docker Compose) replaces manual deployment&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2018-2020&lt;/strong&gt;: Policy as code (OPA, Sentinel) replaces manual compliance checks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2020-2023&lt;/strong&gt;: Visual testing as code (Percy, Chromatic) replaces manual visual QA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2024-present&lt;/strong&gt;: Screenshots as code replaces manual documentation visuals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each wave applies the same core insight: &lt;strong&gt;anything that can be defined declaratively and executed automatically should be.&lt;/strong&gt; Manual processes do not scale, introduce human error, and lack auditability.&lt;/p&gt;

&lt;p&gt;For a deeper look at the costs of the manual approach, see &lt;a href="https://dev.to/visual-debt"&gt;what visual debt actually costs your team&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Objections
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Our screenshots need custom annotations and callouts."
&lt;/h3&gt;

&lt;p&gt;Annotations can be config-driven too. Define bounding boxes, arrow positions, and label text in the capture configuration. The automation applies them consistently every time. This actually produces &lt;em&gt;better&lt;/em&gt; annotations than manual work because placement is pixel-precise and consistent across every screenshot.&lt;/p&gt;

&lt;h3&gt;
  
  
  "We need test data, not production data."
&lt;/h3&gt;

&lt;p&gt;Prefer a localhost build with deterministic seed data. The point is to run the shipped app locally inside CI, not to capture against production URLs. This is the same approach you already use for reliable end-to-end tests.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Our docs team does not know automation."
&lt;/h3&gt;

&lt;p&gt;They do not need to. The config file is JSON -- any technical writer can edit it. The capture runs as a CLI command, and the PR review process is the same one they already use for content changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  "What about edge cases where automation cannot reach the right state?"
&lt;/h3&gt;

&lt;p&gt;Every approach has edge cases. The goal is to automate 80-90% of your screenshots and handle the remaining 10-20% through a documented manual process. Even partial automation dramatically reduces visual debt accumulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;You do not need to convert your entire documentation set overnight. Start with a pilot:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pick 10-20 high-traffic screenshots.&lt;/strong&gt; Choose the ones that appear in your getting-started guide or most-visited docs pages.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Write the scenario config.&lt;/strong&gt; Define the URLs, selectors, and steps needed to reproduce each screenshot.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run it locally.&lt;/strong&gt; Verify that the automated captures match your current screenshots closely enough.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add it to CI.&lt;/strong&gt; Start with monitoring-only mode. Let it run for two weeks and review the diff reports.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Expand gradually.&lt;/strong&gt; Add more captures to the config as you gain confidence in the approach.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://dev.to/docs"&gt;Reshot docs&lt;/a&gt; walk through this workflow end-to-end: config-driven capture, variant management, visual diffing, and local automation.&lt;/p&gt;

&lt;p&gt;The capture half of this is the part you &lt;em&gt;can&lt;/em&gt; build yourself -- Playwright plus a shell script will take a screenshot. What that script will not give you is the hard half: a &lt;strong&gt;review queue&lt;/strong&gt; so a non-engineer can approve a changed screenshot, a &lt;strong&gt;visual registry&lt;/strong&gt; that tracks the approved state of every variant, and &lt;strong&gt;durable URLs&lt;/strong&gt; that flip to the new image on approval so every doc, README, and embed updates at once without a find-and-replace. Owning that layer -- diff storage, approval state, CDN invalidation, dead-link safety across hundreds of embeds -- is a product, not a script. That is the part Reshot manages so your team does not have to. The principles in this post are tool-agnostic; the managed review-and-delivery layer is where the actual leverage lives.&lt;/p&gt;

</description>
      <category>screenshots</category>
      <category>automation</category>
      <category>documentation</category>
      <category>ci</category>
    </item>
    <item>
      <title>PptxGenJS gotchas that silently break your PowerPoint: the complete guide</title>
      <dc:creator>jake</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:30:17 +0000</pubDate>
      <link>https://dev.to/jakexkim/pptxgenjs-gotchas-that-silently-break-your-powerpoint-the-complete-guide-l96</link>
      <guid>https://dev.to/jakexkim/pptxgenjs-gotchas-that-silently-break-your-powerpoint-the-complete-guide-l96</guid>
      <description>&lt;p&gt;PptxGenJS has &lt;span&gt;1.27 million weekly downloads&lt;/span&gt; and is the most popular JavaScript library for PowerPoint generation. It is also full of silent failure modes — bugs that produce corrupted output without throwing errors. Option objects get mutated in-place. Combo charts trigger repair dialogs. Uppercase image paths break files. This guide documents every known gotcha with the broken code and the fix, sourced from GitHub issues, production debugging, and &lt;a href="https://github.com/anthropics/skills/blob/main/skills/pptx/pptxgenjs.md" rel="nofollow noopener noreferrer"&gt;Anthropic's OOXML skills documentation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: option object mutation
&lt;/h2&gt;

&lt;p&gt;Silent corruption — second shape gets wrong dimensions&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://github.com/anthropics/skills/blob/main/skills/pptx/pptxgenjs.md" rel="nofollow noopener noreferrer"&gt;Anthropic's PptxGenJS reference&lt;/a&gt;: "NEVER reuse option objects across calls — PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape."&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;shadow&lt;/span&gt; &lt;span class="o"&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="s2"&gt;outer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&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="na"&gt;y&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="c1"&gt;// ❌ shadow object is now mutated (values converted to EMU)&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix — factory function&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;makeShadow&lt;/span&gt; &lt;span class="o"&gt;=&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="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="s2"&gt;outer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;blur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;makeShadow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// ✅ fresh object — no mutation problem&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;makeShadow&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;This is the most common silent failure in PptxGenJS. The first shape renders correctly; the second shape has wrong dimensions, wrong shadow size, or missing formatting. There is no error, no warning, no repair dialog — just visually wrong output that passes through code review because the code looks correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: combo charts trigger repair dialog
&lt;/h2&gt;

&lt;p&gt;PowerPoint repair — "found a problem with content"&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://github.com/gitbrent/PptxGenJS/releases" rel="nofollow noopener noreferrer"&gt;PptxGenJS release notes&lt;/a&gt;, issue #1013 documented that "chart with lines and bars produces repair file dialog in PowerPoint." The fix was released in v4.0.1 (June 2025), but combo charts with &lt;code&gt;secondaryValAxis&lt;/code&gt; and &lt;code&gt;secondaryCatAxis&lt;/code&gt; can still produce repair dialogs when the options are inconsistent.&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;chartTypes&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="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BAR&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;barData&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;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BAR&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;avgData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;secondaryValAxis&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;secondaryCatAxis&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="c1"&gt;// ❌ causes repair on Windows Office&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="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chartTypes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix — hide secondary category axis&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;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;valAxes&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="na"&gt;showValAxisTitle&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;valGridLine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;solid&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;valAxisHidden&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;valGridLine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// ✅ hide secondary&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chartTypes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This issue was platform-specific — files opened correctly in LibreOffice on macOS but triggered repair on Windows Office. Always test PptxGenJS output on Windows PowerPoint, not just macOS or web viewers. For the full OOXML explanation of why this happens, see &lt;a href="https://dev.to/blog/ai-pptx-repair-dialog-ooxml"&gt;why PPTX triggers repair dialogs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: uppercase image paths
&lt;/h2&gt;

&lt;p&gt;Repair dialog — image path case sensitivity&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://github.com/gitbrent/PptxGenJS/releases" rel="nofollow noopener noreferrer"&gt;PptxGenJS v4.0.1 release notes&lt;/a&gt;, issue #860 documented that "using addImage() with uppercase path prop causes needs to repair presentation." OOXML relationship targets are case-sensitive inside the ZIP archive.&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="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addImage&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./images/logo.PNG&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// ❌ .PNG (uppercase)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fix — lowercase paths&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="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addImage&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./images/logo.png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// ✅ .png (lowercase)&lt;/span&gt;

&lt;span class="c1"&gt;// Or normalize programmatically:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;safePath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;imagePath&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="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addImage&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="nx"&gt;safePath&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha 4: "Edit Data in Excel" broken on charts
&lt;/h2&gt;

&lt;p&gt;Charts not editable — embedded workbook corrupt&lt;/p&gt;

&lt;p&gt;According to the PptxGenJS v4.0.1 release notes, "several issues with charts embedded Excel sheets that prevented Edit Data in Excel from working" were fixed. If you are on an older version, chart data cannot be edited by the recipient — clicking "Edit Data" in PowerPoint either does nothing or shows an error.&lt;/p&gt;

&lt;p&gt;Fix — upgrade to v4.0.1+&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;pptxgenjs@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If upgrading is not possible, the chart data is still correct — only the "Edit Data in Excel" feature is broken. The chart displays correctly; it just cannot be modified by the recipient.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 5: ROUNDED_RECTANGLE with accent borders
&lt;/h2&gt;

&lt;p&gt;Visual bug — rectangular overlay bars on rounded corners&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://github.com/anthropics/skills/blob/main/skills/pptx/pptxgenjs.md" rel="nofollow noopener noreferrer"&gt;Anthropic's reference&lt;/a&gt;: "Don't use ROUNDED_RECTANGLE with accent borders — rectangular overlay bars won't cover rounded corners."&lt;/p&gt;

&lt;p&gt;Fix — use RECTANGLE 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="c1"&gt;// ❌ ROUNDED_RECTANGLE + colored border = visual artifact&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ROUNDED_RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;rectRadius&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4580B0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&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="c1"&gt;// ✅ Use RECTANGLE — clean border rendering&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addShape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;shapes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RECTANGLE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;line&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;4580B0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gotcha 6: single-series bar chart with chartColors
&lt;/h2&gt;

&lt;p&gt;Visual bug — chart renders broken with color array&lt;/p&gt;

&lt;p&gt;According to &lt;a href="https://github.com/gitbrent/PptxGenJS/issues/285" rel="nofollow noopener noreferrer"&gt;issue #285&lt;/a&gt;, providing a &lt;code&gt;chartColors&lt;/code&gt; array to a bar chart with a single data series causes the chart to render incorrectly. The legend shows all category labels as separate entries, and the bars display wrong colors.&lt;/p&gt;

&lt;p&gt;Fix — single-element color array&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;// ❌ Array of colors with single series = broken rendering&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BAR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;chartColors&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;4580B0&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;C4453A&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;B58215&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="c1"&gt;// ✅ Single-element array for single series&lt;/span&gt;
&lt;span class="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;charts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BAR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;chartColors&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;4580B0&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;h2&gt;
  
  
  Gotcha 7: table auto-paging with complex text
&lt;/h2&gt;

&lt;p&gt;Content overflow — text runs break pagination&lt;/p&gt;

&lt;p&gt;According to the PptxGenJS release notes, table auto-paging was "completely re-written from scratch" in v3.12 to handle complex text (text runs with mixed formatting). If you are on an older version, tables with styled text runs (bold + italic + links in the same cell) may overflow the slide without creating a new page.&lt;/p&gt;

&lt;p&gt;Fix — upgrade to v3.12+ and use autoPageRepeatHeader&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="nx"&gt;slide&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTable&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;autoPage&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;autoPageRepeatHeader&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;autoPageLineWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// reduce for tighter fits&lt;/span&gt;
  &lt;span class="na"&gt;autoPageCharWeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// reduce for more chars/line&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;autoPageLineWeight&lt;/code&gt; and &lt;code&gt;autoPageCharWeight&lt;/code&gt; options control how aggressively the auto-pager breaks content. Lower values fit more content per slide at the cost of tighter spacing. Test with your actual data — the defaults may over-paginate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternative: skip OOXML entirely
&lt;/h2&gt;

&lt;p&gt;Every gotcha above stems from the same root cause: PptxGenJS generates raw OOXML, and OOXML has strict element ordering, relationship ID, and content type requirements that are easy to violate. The &lt;a href="https://dev.to/blog/json-layer-pattern-ai-document-generation"&gt;JSON layer pattern&lt;/a&gt; avoids these issues entirely — you define the document as JSON, and a rendering engine handles OOXML compliance.&lt;/p&gt;

&lt;p&gt;PaperJSX's free PPTX engine (MIT-licensed) generates valid OOXML from JSON schemas. No option object mutation, no relationship ID management, no element ordering concerns. The same JSON produces PPTX, PDF, DOCX, and XLSX. For the free PPTX quickstart, see &lt;a href="https://dev.to/blog/generate-pptx-from-json"&gt;Generate PPTX from JSON&lt;/a&gt;. For a migration path from PptxGenJS, see &lt;a href="https://dev.to/blog/python-pptx-vs-pptxgenjs-vs-paperjsx"&gt;the three-way comparison&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Avoid OOXML gotchas entirely — &lt;a href="/blog/generate-pptx-from-json"&gt;generate PPTX from JSON&lt;/a&gt; with PaperJSX (MIT-licensed), or see the &lt;a href="/blog/python-pptx-vs-pptxgenjs-vs-paperjsx"&gt;PptxGenJS comparison&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>pptx</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Convert DOCX to PDF in Node.js: every option compared</title>
      <dc:creator>jake</dc:creator>
      <pubDate>Mon, 22 Jun 2026 14:30:15 +0000</pubDate>
      <link>https://dev.to/jakexkim/convert-docx-to-pdf-in-nodejs-every-option-compared-1oee</link>
      <guid>https://dev.to/jakexkim/convert-docx-to-pdf-in-nodejs-every-option-compared-1oee</guid>
      <description>&lt;p&gt;There is no free, pure-JavaScript library that reliably converts DOCX to PDF. DOCX is a complex format — paragraph spacing, section breaks, table layout, headers, footers, font metrics — and rendering it as PDF requires a layout engine that understands Word's formatting model. Every approach involves a trade-off: &lt;span&gt;LibreOffice&lt;/span&gt; (free, 500 MB binary), &lt;span&gt;commercial SDKs&lt;/span&gt; (no external deps, $$$), &lt;span&gt;hosted APIs&lt;/span&gt; (per-document pricing), or &lt;span&gt;skip conversion&lt;/span&gt; (generate both formats from JSON). Here is every option, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Tools&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;External binary&lt;/th&gt;
&lt;th&gt;Serverless&lt;/th&gt;
&lt;th&gt;Fidelity&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LibreOffice headless&lt;/td&gt;
&lt;td&gt;libreoffice-convert, unoconv, Gotenberg&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;500+ MB&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commercial SDK&lt;/td&gt;
&lt;td&gt;Nutrient, Apryse, Aspose.Words&lt;/td&gt;
&lt;td&gt;$500–$2,000+/yr&lt;/td&gt;
&lt;td&gt;Native binary (50–200 MB)&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted API&lt;/td&gt;
&lt;td&gt;ConvertAPI, CloudConvert, Adobe PDF Services&lt;/td&gt;
&lt;td&gt;Per-document&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skip conversion&lt;/td&gt;
&lt;td&gt;PaperJSX&lt;/td&gt;
&lt;td&gt;$149/mo&lt;/td&gt;
&lt;td&gt;None (pure JS)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Schema-defined&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Option 1: LibreOffice headless (free, heavy)
&lt;/h2&gt;

&lt;p&gt;LibreOffice implements a Word-compatible layout engine. The &lt;code&gt;libreoffice-convert&lt;/code&gt; npm package wraps it in a Node.js-friendly API. According to &lt;a href="https://www.nutrient.io/blog/how-to-convert-word-to-pdf-in-nodejs/" rel="nofollow noopener noreferrer"&gt;Nutrient's comparison&lt;/a&gt;, this approach "works well for basic conversion needs" but requires managing LibreOffice as a system dependency.&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;convert&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="s2"&gt;libreoffice-convert&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&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="s2"&gt;node:fs&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;promisify&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="s2"&gt;node:util&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;convertAsync&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;promisify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;convert&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;docx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.docx&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;pdf&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;convertAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;docx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;undefined&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice.pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pdf&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Free. Best open-source fidelity for complex Word layouts. Handles headers, footers, section breaks, embedded images, tracked changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Requires LibreOffice installed on the host (~500 MB). Spawns external processes — needs process management and health checks. Docker images are 500+ MB. Cannot run on Vercel, Cloudflare Workers, or standard Lambda. Output varies between LibreOffice versions. According to Nutrient's analysis, it offers "limited error reporting compared to integrated solutions."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker alternative:&lt;/strong&gt; &lt;a href="https://gotenberg.dev/" rel="nofollow noopener noreferrer"&gt;Gotenberg&lt;/a&gt; packages LibreOffice and Chromium in a Docker container with an HTTP API. Run it as a sidecar service — your app sends the DOCX via HTTP and receives the PDF. This isolates the LibreOffice dependency from your application container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: Commercial SDKs (no LibreOffice)
&lt;/h2&gt;

&lt;p&gt;Three commercial SDKs implement their own DOCX layout engines, eliminating the LibreOffice dependency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nutrient Node.js SDK&lt;/strong&gt; (formerly PSPDFKit): According to &lt;a href="https://www.nutrient.io/guides/nodejs/conversion/office-to-pdf/" rel="nofollow noopener noreferrer"&gt;Nutrient's documentation&lt;/a&gt;, it "relies entirely on its own technology built from the ground up, and it doesn't depend on third-party tools such as LibreOffice or Microsoft Office." Handles font substitution automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apryse Server SDK&lt;/strong&gt;: According to &lt;a href="https://apryse.com/blog/nodejs/generate-pdf-convert-docx-to-pdf-with-nodejs-v2" rel="nofollow noopener noreferrer"&gt;Apryse's guide&lt;/a&gt;, it converts "DOCX files to PDF using Apryse's Server SDK in Node.js without third-party dependencies." Note: it installs native binaries (system-specific distributions).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aspose.Words for Node.js&lt;/strong&gt;: According to &lt;a href="https://docs.aspose.com/words/nodejs-net/convert-a-document-to-pdf/" rel="nofollow noopener noreferrer"&gt;Aspose's documentation&lt;/a&gt;, conversion is two lines: &lt;code&gt;let doc = new aw.Document("input.docx"); doc.save("output.pdf");&lt;/code&gt;. Supports PDF/A compliance. Pricing starts at $1,199/yr per developer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; No LibreOffice. High fidelity. PDF/A compliance options. Enterprise support.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Commercial licensing ($500–$2,000+/yr). Native binaries may not fit serverless size limits. Vendor lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: Hosted APIs (no local deps)
&lt;/h2&gt;

&lt;p&gt;Hosted conversion APIs accept a DOCX file via HTTP and return a PDF. Your application has zero local dependencies — the conversion happens on the provider's infrastructure.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;ConvertAPI&lt;/strong&gt; — &lt;a href="https://www.convertapi.com/docx-to-pdf/nodejs" rel="nofollow noopener noreferrer"&gt;Node.js SDK&lt;/a&gt;, per-document pricing, supports 200+ conversions&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;CloudConvert&lt;/strong&gt; — REST API, 25 free conversions/day, then per-document&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Adobe PDF Services API&lt;/strong&gt; — 500 free transactions/month, SDKs in Node.js/.NET/Java&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Gotenberg Cloud&lt;/strong&gt; — hosted Gotenberg, no Docker management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Zero local dependencies. Works from any environment including serverless. No LibreOffice, no native binaries. High fidelity (most use LibreOffice or custom engines server-side).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Per-document cost. Network dependency (latency, availability). Data leaves your infrastructure — privacy/compliance concern for sensitive documents. Not suitable for high-volume batch generation (cost scales linearly with volume).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Low-to-medium volume conversion (under 10,000 documents/month), where the per-document cost is lower than the engineering cost of managing LibreOffice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 4: Skip conversion — generate both from JSON
&lt;/h2&gt;

&lt;p&gt;If you control the document's content (it's generated by your code, not uploaded by users), you can skip the conversion step entirely. Generate the DOCX and the PDF independently from the same source data.&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;generate&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;toDocx&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="s2"&gt;@paperjsx/json-to-docx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;toPdf&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="s2"&gt;@paperjsx/json-to-pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;writeFileSync&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="s2"&gt;node:fs&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;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&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="s2"&gt;Q3 Report&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;pdfua&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;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;elements&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="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="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Q3 Report&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bold&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="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="s2"&gt;table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&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;Region&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;Revenue&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;rows&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;NA&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;$4,200&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;EMEA&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;$3,100&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;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Same schema → two files, no conversion step&lt;/span&gt;
&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;report.docx&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="nf"&gt;toDocx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;report.pdf&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="nf"&gt;toPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Zero external dependencies (pure JS, &lt;a href="https://dev.to/blog/pdf-without-puppeteer"&gt;no Puppeteer, no LibreOffice&lt;/a&gt;). Works on every runtime including &lt;a href="https://dev.to/blog/supabase-edge-functions-document-generation"&gt;Edge Functions&lt;/a&gt; and &lt;a href="https://dev.to/blog/generate-pptx-hono-bun"&gt;Cloudflare Workers&lt;/a&gt;. No quality loss from format translation. PDF output can include &lt;a href="https://dev.to/blog/pdf-ua-compliance-nodejs"&gt;PDF/UA accessibility&lt;/a&gt;. Same schema also produces PPTX and XLSX.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt; Cannot convert existing DOCX files — only works for new documents defined in JSON. Layout is schema-defined, not pixel-for-pixel identical to Word's rendering. Requires PaperJSX All Formats plan ($149/mo). &lt;strong&gt;Disclosure:&lt;/strong&gt; PaperJSX is my product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Applications that generate documents from database data (invoices, reports, certificates) and need both DOCX and PDF output. See the &lt;a href="https://dev.to/blog/multi-format-document-generation"&gt;multi-format tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision framework
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;&lt;tr&gt;
&lt;th&gt;If your situation is…&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Converting user-uploaded DOCX files to PDF&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;LibreOffice&lt;/strong&gt; (free) or &lt;strong&gt;hosted API&lt;/strong&gt; (serverless)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker-based deployment, budget-conscious&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Gotenberg&lt;/strong&gt; (LibreOffice in Docker, HTTP API)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise, no external deps, high fidelity&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Nutrient&lt;/strong&gt; or &lt;strong&gt;Aspose.Words&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless / edge, low volume&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Hosted API&lt;/strong&gt; (ConvertAPI, CloudConvert)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Generating new documents, need DOCX + PDF&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;PaperJSX&lt;/strong&gt; (no conversion step)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Need PDF/UA accessible output&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;PaperJSX&lt;/strong&gt; or &lt;strong&gt;Aspose.Words&lt;/strong&gt; (PDF/A)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch converting 10K+ existing DOCX files&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;LibreOffice&lt;/strong&gt; + &lt;a href="/blog/batch-document-generation"&gt;BullMQ queue&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why no pure-JS converter exists
&lt;/h2&gt;

&lt;p&gt;DOCX rendering requires implementing Word's layout engine — a system that handles paragraph spacing rules, widow/orphan control, section breaks with independent page setups, table auto-sizing with preferred widths, floating objects with text wrapping, header/footer logic with different first-page and odd/even variants, font metric calculation for precise line breaking, and footnote/endnote placement. LibreOffice has implemented this over 20+ years. Aspose and Nutrient maintain proprietary implementations. No open-source JavaScript project has attempted this because the scope is equivalent to building a word processor.&lt;/p&gt;

&lt;p&gt;This is different from PDF generation (where libraries like pdfmake and PaperJSX work well) because PDF generation starts from structured data — the library controls the layout. DOCX conversion starts from someone else's layout — the library must reproduce it precisely.&lt;/p&gt;

&lt;p&gt;Skip the conversion step — &lt;a href="/blog/json-to-docx"&gt;generate DOCX from JSON&lt;/a&gt;, &lt;a href="/blog/generate-pdf-nextjs"&gt;generate PDF from JSON&lt;/a&gt;, or see the &lt;a href="/blog/multi-format-document-generation"&gt;multi-format tutorial&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>node</category>
      <category>pdf</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
