🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@unpaper/cli

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@unpaper/cli - npm Package Compare versions

Comparing version
0.0.1
to
0.2.0
dist/core.mjs

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display

+824
{
"$generated": "Built from the open pattern library by scripts/build-patterns.mjs — DO NOT EDIT BY HAND. Run `npm run patterns:build` after changing ../unpaper-html/patterns.",
"version": 1,
"source": "unpaper-html/patterns@c83811e",
"family": "consulting",
"familyRule": "Action titles: each slide title is a full-sentence finding, and the titles read top-to-bottom as the whole argument. One message per slide. Answer first (pyramid), then MECE support, then next steps. Evidence sits beside its claim; every data slide carries a 'Source:' line. Colour directs attention, it does not decorate.",
"patterns": {
"C01": {
"id": "C01",
"label": "Cover",
"ready": true,
"tier": 1,
"compact": "Cover: the document title slide — a short topic title (one accent word via <em>), a one-line subtitle, and client/date/confidentiality in the corners. Set the scene; no body content.",
"primitives": [
"h1.cover-title",
"cover-lede"
],
"support": "native",
"level": "L1",
"tokens": 46,
"demo": "cover"
},
"C02": {
"id": "C02",
"label": "Executive summary",
"ready": true,
"tier": 1,
"compact": "Executive summary: open with the recommendation in one bold line, then 3-5 supporting findings as a numbered list or a stat row. State the answer first; the rest of the deck defends it.",
"primitives": [
"h2.title",
"data-unpaper-lay=2l",
".stats",
"ol/li"
],
"support": "partial",
"level": "L2",
"tokens": 47,
"demo": "exec-summary"
},
"C03": {
"id": "C03",
"label": "Agenda",
"ready": true,
"tier": 1,
"compact": "Agenda / storyline: preview the argument as 3-6 numbered sections with action-oriented names (a claim each, not a label) — reading them should sketch the whole story. A simple numbered list.",
"primitives": [
"ol/li",
".eyebrow"
],
"support": "native",
"level": "L2",
"tokens": 48,
"demo": null
},
"C04": {
"id": "C04",
"label": "Section divider",
"ready": true,
"tier": 1,
"compact": "Section divider: a full-slide chapter break — a large section number plus a short section title on a clean field. Marks a shift; carries no evidence.",
"primitives": [
".divider",
".eyebrow"
],
"support": "native",
"level": "L1",
"tokens": 38,
"demo": "divider"
},
"C05": {
"id": "C05",
"label": "Action-title evidence",
"ready": true,
"tier": 1,
"compact": "Action-title evidence slide: the title states the finding as a full sentence; use data-unpaper-lay='2l' with a short so-what (2-3 bullets) on the left and ONE proof object (chart or table) on the right; put a 'Source:' line directly under the evidence.",
"primitives": [
"h2.title",
"data-unpaper-lay=2l",
"data-unpaper-chart",
"table",
".source"
],
"support": "native",
"level": "L2",
"tokens": 63,
"demo": "split"
},
"C06": {
"id": "C06",
"label": "Single chart proof",
"ready": true,
"tier": 1,
"compact": "Single chart proof: one data-unpaper-chart carries the whole point; the title states what the chart proves; one source line below; no second visual.",
"primitives": [
"data-unpaper-chart",
"h2.title",
".source"
],
"support": "native",
"level": "L3",
"tokens": 37,
"demo": "chart"
},
"C07": {
"id": "C07",
"label": "Small multiples",
"ready": true,
"tier": 2,
"compact": "Small multiples: 2-4 charts of the SAME type and identical scale side by side (data-unpaper-lay='2' or '3') to compare segments or periods; one shared source line.",
"primitives": [
"data-unpaper-lay=2|3",
"data-unpaper-chart",
".source"
],
"support": "native",
"level": "L3",
"tokens": 41,
"demo": "small-multiples"
},
"C08": {
"id": "C08",
"label": "Waterfall / bridge",
"ready": true,
"tier": 3,
"compact": "Waterfall/bridge (.wf block): walk a total from start to end through +/- steps. Position each bar by its running total in baked px (baseline at the plot bottom, scale ~2.5px/unit; .up green / .down red / .total dark); join steps with thin .wf-conn lines at the pre-step total; label each delta (+/-) and the start/end totals. Copy the Value-bridge slide's geometry and keep the plot within the slide so it converts to native shapes.",
"primitives": [
".wf",
".wf-bar.up/.down/.total",
".wf-conn",
".wf-val/.wf-cat"
],
"support": "native",
"level": "L3",
"tokens": 108,
"demo": "waterfall"
},
"C09": {
"id": "C09",
"label": "Variance decomposition",
"ready": true,
"tier": 3,
"compact": "Variance decomposition: break a total change into its drivers — a waterfall (.wf) or a chart plus a .stats row naming each driver contribution. Show what moved the number and by how much.",
"primitives": [
".wf",
".stats",
"data-unpaper-chart"
],
"support": "partial",
"level": "L3",
"tokens": 47,
"demo": null
},
"C10": {
"id": "C10",
"label": "KPI stat row",
"ready": true,
"tier": 1,
"compact": "KPI stat row (.stats): anchor the story in 2-4 big numbers, each with a tiny label and optional delta. One row of native text — the headline metrics, not a table.",
"primitives": [
".stats"
],
"support": "native",
"level": "L2",
"tokens": 41,
"demo": "stats"
},
"C11": {
"id": "C11",
"label": "Benchmark ranking",
"ready": true,
"tier": 2,
"compact": "Benchmark ranking: rank companies / countries / segments / options on one metric — a sorted horizontal bar chart (data-unpaper-chart) or a ranked table, highest to lowest, with the leader called out. Keep labels short.",
"primitives": [
"data-unpaper-chart",
"table"
],
"support": "native",
"level": "L3",
"tokens": 55,
"demo": null
},
"C12": {
"id": "C12",
"label": "2x2 matrix",
"ready": true,
"tier": 2,
"compact": "2x2 matrix: position options on two named axes using the matrix block (.matrix.axes) with four quadrants, clear axis labels, and the priority cell marked; plot 4-8 items max.",
"primitives": [
".matrix.axes",
".quad",
".mxlabel"
],
"support": "native",
"level": "L2",
"tokens": 44,
"demo": "framework"
},
"C13": {
"id": "C13",
"label": "Issue tree",
"ready": true,
"tier": 2,
"compact": "Issue tree: break the question into MECE branches (a parent box over 2-4 child boxes, each splitting further if needed) using the org/tree block; branches must be mutually exclusive and collectively exhaustive.",
"primitives": [
".orgtree",
".org-node"
],
"support": "partial",
"level": "L2",
"tokens": 53,
"demo": "issue-tree"
},
"C14": {
"id": "C14",
"label": "Driver tree",
"ready": true,
"tier": 3,
"compact": "Driver tree / value equation: use the .dt idiom — absolute .dt-node boxes (root left, levers right; give each data-unpaper-cx=\"id\"), joined by .dt-cx svg polylines tagged data-unpaper-connect=\"fromId toId\" (route edge-midpoint to edge-midpoint, bend at the horizontal midpoint), with .dt-op chips (× + −) between sibling boxes. Keep the tree ≤10 boxes and ≤344px tall. Every box converts to a movable native shape and every line to a REAL PowerPoint connector that re-routes when a box is dragged.",
"primitives": [
".dt",
".dt-node (absolute, data-unpaper-cx id)",
".dt-cx svg polyline (data-unpaper-connect)",
".dt-op operator chip"
],
"support": "native",
"level": "L2",
"tokens": 125,
"demo": "driver-tree"
},
"C15": {
"id": "C15",
"label": "Option comparison",
"ready": true,
"tier": 1,
"compact": "Option comparison: a real <table> with options as rows and criteria as columns (<=6 columns); mark the recommended row or column; one source line below.",
"primitives": [
"table.tbl",
"thead",
"tbody"
],
"support": "native",
"level": "L2",
"tokens": 38,
"demo": "table"
},
"C16": {
"id": "C16",
"label": "Scorecard (Harvey balls)",
"ready": true,
"tier": 2,
"compact": "Scorecard: a scorecard grid of options x criteria; each cell a Harvey-ball rating (data-unpaper-rating 0-1, drawn as a filled-pie SVG: empty, quarter, half, three-quarter, full) or a RAG colour; add a one-line legend and a source line. Use the .scorecard grid, not a native <table>, so each ball stays a movable shape.",
"primitives": [
".scorecard",
"data-unpaper-rating",
"inline svg pie",
".legend",
".source"
],
"support": "native",
"level": "L3",
"tokens": 80,
"demo": "scorecard"
},
"C17": {
"id": "C17",
"label": "Maturity ladder",
"ready": true,
"tier": 2,
"compact": "Maturity ladder: levels from current to target state — 3-5 stacked rungs (cards or a vertical .timeline), each a level name plus a one-line descriptor, with today level marked. Show the climb.",
"primitives": [
".cards",
".timeline"
],
"support": "partial",
"level": "L2",
"tokens": 48,
"demo": null
},
"C18": {
"id": "C18",
"label": "Roadmap / phases",
"ready": true,
"tier": 2,
"compact": "Roadmap: 3-5 phases over time using the timeline block, each with a date/label and 1-2 outcomes, laid out left to right.",
"primitives": [
".timeline",
".tl-step",
".when"
],
"support": "native",
"level": "L2",
"tokens": 30,
"demo": "timeline"
},
"C19": {
"id": "C19",
"label": "Workplan (swimlanes)",
"ready": true,
"tier": 3,
"compact": "Workplan / swimlanes (Gantt-lite): use the .wp idiom — absolute-positioned .wp-lane labels down the left, quarter .wp-tick headers with .wp-grid lines, one .wp-bar per phase (left/width in baked px: plot spans x 130-1050, one quarter = 230px; alternate .wp-bar and .wp-bar.soft), a .wp-ms dot + .wp-mslabel for a milestone. Keep the plot <=250px tall. Every bar converts to a movable native shape.",
"primitives": [
".wp",
".wp-lane",
".wp-bar (absolute, baked px)",
".wp-ms milestone dot",
".wp-tick/.wp-grid"
],
"support": "native",
"level": "L2",
"tokens": 100,
"demo": "workplan"
},
"C20": {
"id": "C20",
"label": "Process flow",
"ready": true,
"tier": 2,
"compact": "Process flow: 3-5 ordered steps joined by arrows using the flow block; one short label per step; arrows in the accent colour.",
"primitives": [
".flow",
".node",
"svg.arrow"
],
"support": "native",
"level": "L2",
"tokens": 32,
"demo": "flow"
},
"C21": {
"id": "C21",
"label": "Operating model",
"ready": true,
"tier": 3,
"compact": "Operating model: capabilities, roles and interfaces — a grid of capability boxes (cards) grouped into layers, or an .orgtree of functions. Show the structure, not a process.",
"primitives": [
".cards",
".orgtree"
],
"support": "partial",
"level": "L2",
"tokens": 44,
"demo": null
},
"C22": {
"id": "C22",
"label": "Org / governance",
"ready": true,
"tier": 2,
"compact": "Org / governance (.orgtree): show accountability and escalation — a small hierarchy of boxes (sponsor to lead to workstreams). Native boxes plus connectors; keep to 2-3 levels.",
"primitives": [
".orgtree"
],
"support": "native",
"level": "L2",
"tokens": 44,
"demo": null
},
"C23": {
"id": "C23",
"label": "Initiative portfolio",
"ready": true,
"tier": 2,
"compact": "Initiative portfolio: sort ~6-8 initiatives by impact x effort on a 2x2, or as status-tagged cards; mark the quick wins.",
"primitives": [
".matrix.axes",
".cards",
".ci"
],
"support": "native",
"level": "L2",
"tokens": 30,
"demo": null
},
"C24": {
"id": "C24",
"label": "Business case",
"ready": true,
"tier": 3,
"compact": "Business case / value sizing: size the value and its financials — a waterfall (.wf) bridging from baseline to the value pool, plus an assumptions table. State the number and what it rests on.",
"primitives": [
".wf",
"table"
],
"support": "partial",
"level": "L3",
"tokens": 48,
"demo": null
},
"C25": {
"id": "C25",
"label": "Risk & mitigation",
"ready": true,
"tier": 2,
"compact": "Risk and mitigation: pair each risk with its control — a table (Risk | Likelihood | Impact | Mitigation | Owner) or paired cards. One row per risk; rate likelihood/impact with words or Harvey balls.",
"primitives": [
"table",
".cards",
"data-unpaper-rating"
],
"support": "native",
"level": "L2",
"tokens": 50,
"demo": null
},
"C26": {
"id": "C26",
"label": "Stakeholder map",
"ready": true,
"tier": 3,
"compact": "Stakeholder map: place stakeholders on influence x interest — a 2x2 (data-unpaper-lay=quad / .matrix.axes) with named dots per quadrant and a one-line engagement note per zone.",
"primitives": [
".matrix.axes",
"data-unpaper-lay=quad"
],
"support": "partial",
"level": "L2",
"tokens": 44,
"demo": null
},
"C27": {
"id": "C27",
"label": "Customer journey",
"ready": true,
"tier": 3,
"compact": "Customer journey: use the .jy idiom — a .jy-line spine with a .jy-grid of 4-5 .jy-stage columns; each stage a numbered .jy-dot, an h3 stage name, a .jy-pain line (bold the pain itself), and a .jy-mo panel (\"Moment that matters\" + one line). The title states where the journey breaks. Dots and panels convert to movable native shapes.",
"primitives": [
".jy",
".jy-line spine",
".jy-dot (numbered ellipse)",
".jy-pain",
".jy-mo moment panel"
],
"support": "native",
"level": "L2",
"tokens": 84,
"demo": "journey"
},
"C28": {
"id": "C28",
"label": "Segment comparison",
"ready": true,
"tier": 2,
"compact": "Segment / persona comparison: compare 2-4 customer or market segments across the same few attributes — a table with one column per segment (or cards), so differences read across a row.",
"primitives": [
"table",
".cards"
],
"support": "native",
"level": "L2",
"tokens": 46,
"demo": null
},
"C29": {
"id": "C29",
"label": "Case example",
"ready": true,
"tier": 1,
"compact": "Case example: one proof point or vignette — a short narrative beside a pulled-out result, via data-unpaper-lay=2l or a .callout. Make the abstract concrete with a single real case.",
"primitives": [
"data-unpaper-lay=2l",
".callout"
],
"support": "native",
"level": "L2",
"tokens": 45,
"demo": null
},
"C30": {
"id": "C30",
"label": "Recommendation close",
"ready": true,
"tier": 1,
"compact": "Recommendation close: the decision/ask in one bold line, the 2-3 reasons, and the first concrete next step with an owner and a date.",
"primitives": [
"h2.title",
".bignum",
"ol/li",
".callout"
],
"support": "native",
"level": "L2",
"tokens": 33,
"demo": "closing"
},
"C31": {
"id": "C31",
"label": "Appendix table",
"ready": true,
"tier": 3,
"compact": "Appendix data table: preserve the detailed evidence — a compact, dense table with many rows, small type and clear headers. For backup; not a headline slide.",
"primitives": [
"table"
],
"support": "native",
"level": "L2",
"tokens": 39,
"demo": null
},
"C32": {
"id": "C32",
"label": "Methodology",
"ready": true,
"tier": 3,
"compact": "Methodology / sources: explain the research basis — a short list of method steps and the data sources used, as bullets or a .callout. Builds trust; keep it brief.",
"primitives": [
".callout",
"ul/li",
".source"
],
"support": "partial",
"level": "L2",
"tokens": 41,
"demo": null
},
"C33": {
"id": "C33",
"label": "Assumptions",
"ready": true,
"tier": 3,
"compact": "Assumptions / limitations: state the model boundaries — a short bulleted list or .callout of the key assumptions and what the analysis does not cover. Be explicit and honest.",
"primitives": [
".callout",
"ul/li"
],
"support": "native",
"level": "L2",
"tokens": 44,
"demo": null
},
"C34": {
"id": "C34",
"label": "Glossary",
"ready": true,
"tier": 3,
"compact": "Glossary / definitions: define the terms — a two-column list of term plus short definition (a table or definition list). Alphabetical; one line each.",
"primitives": [
"table",
"dl"
],
"support": "native",
"level": "L2",
"tokens": 38,
"demo": null
},
"C35": {
"id": "C35",
"label": "Before / after",
"ready": true,
"tier": 2,
"compact": "Before / after: current state vs target state side by side — data-unpaper-lay=2 with Today on the left and Target on the right, the same dimensions in each, so the gap is obvious.",
"primitives": [
"data-unpaper-lay=2"
],
"support": "native",
"level": "L2",
"tokens": 45,
"demo": null
},
"C36": {
"id": "C36",
"label": "Recommendation tree",
"ready": true,
"tier": 3,
"compact": "Recommendation tree: link the recommendation to its actions — an .orgtree (or rows) from the headline recommendation down to 2-4 supporting actions/owners. Show how the ask decomposes into moves.",
"primitives": [
".orgtree",
"ol/li"
],
"support": "partial",
"level": "L2",
"tokens": 49,
"demo": null
},
"C37": {
"id": "C37",
"label": "Mixed-chart dashboard",
"ready": true,
"tier": 3,
"compact": "Mixed-chart dashboard: charts of DIFFERENT types on one slide — a doughnut share beside a bar ranking (.charts.c2), or a quad grid (data-unpaper-lay='quad') of small bar / line / doughnut tiles plus one big .stat number for a KPI review. One message per tile with its own small label, direct labels over legends, and the action title states the combined so-what; each data-unpaper-chart wrapper holds ONLY its svg.",
"primitives": [
"data-unpaper-lay=quad",
".charts.c2",
"data-unpaper-chart",
".stat"
],
"support": "native",
"level": "L3",
"tokens": 104,
"demo": "dashboard"
}
},
"subtypes": [
{
"id": "general",
"label": "General",
"hint": "Balanced, all-purpose",
"desc": "A balanced, all-purpose consulting deck — a safe start when you're not sure.",
"patterns": [
"C02",
"C05",
"C06",
"C15",
"C37",
"C30"
],
"readyPatterns": [
"C02",
"C05",
"C06",
"C15",
"C37",
"C30"
],
"tokens": 322
},
{
"id": "recommendation",
"label": "Recommendation",
"hint": "Answer first, then the case",
"desc": "Make a case and land a decision — the answer first, the evidence, then the ask.",
"patterns": [
"C02",
"C05",
"C15",
"C16",
"C30",
"C08",
"C14"
],
"readyPatterns": [
"C02",
"C05",
"C15",
"C16",
"C30",
"C08",
"C14"
],
"tokens": 494
},
{
"id": "market-study",
"label": "Market study",
"hint": "Size it, map it, rank it",
"desc": "Size and map a market — charts, rankings and option comparisons.",
"patterns": [
"C05",
"C06",
"C07",
"C12",
"C37",
"C15",
"C27"
],
"readyPatterns": [
"C05",
"C06",
"C07",
"C12",
"C37",
"C15",
"C27"
],
"tokens": 411
},
{
"id": "transformation",
"label": "Transformation",
"hint": "From today to a target state",
"desc": "Plan a change journey — issue trees, a roadmap, process flows, initiatives.",
"patterns": [
"C13",
"C18",
"C20",
"C23",
"C30",
"C19",
"C14"
],
"readyPatterns": [
"C13",
"C18",
"C20",
"C23",
"C30",
"C19",
"C14"
],
"tokens": 403
},
{
"id": "research-readout",
"label": "Research readout",
"hint": "Findings, charts, method",
"desc": "Share findings — charts and small multiples with a clear method.",
"patterns": [
"C02",
"C06",
"C07",
"C12",
"C37",
"C16",
"C27"
],
"readyPatterns": [
"C02",
"C06",
"C07",
"C12",
"C37",
"C16",
"C27"
],
"tokens": 437
},
{
"id": "exec-briefing",
"label": "Exec briefing",
"hint": "A tight board memo",
"desc": "Brief leadership fast — a tight, dense board memo.",
"patterns": [
"C02",
"C05",
"C15",
"C37",
"C30"
],
"readyPatterns": [
"C02",
"C05",
"C15",
"C37",
"C30"
],
"tokens": 285
}
],
"archetypes": [
{
"id": "boardroom-classic",
"label": "Boardroom Classic",
"hint": "White, restrained, dense evidence",
"desc": "White, restrained, dense evidence — the safe default.",
"accent": "default",
"traits": [
"Dense evidence",
"Footnotes & sources",
"Restrained colour",
"Tables over prose"
],
"rule": "white background, restrained and dense; footnotes and source lines; evidence packed but calm — let the numbers carry it."
},
{
"id": "modern-insight",
"label": "Modern Insight",
"hint": "Open whitespace, one idea per slide",
"desc": "Lots of whitespace and simple charts — one idea per slide.",
"accent": "teal",
"traits": [
"Generous whitespace",
"One idea per slide",
"Simple charts",
"Very few boxes"
],
"rule": "generous whitespace, one confident accent, simple charts and small multiples, very few boxes — make one idea breathe per slide."
},
{
"id": "operator-redline",
"label": "Operator Redline",
"hint": "Strong top rule, blunt and commercial",
"desc": "Punchy and commercial — a strong top rule, accent used sparingly.",
"accent": "coral",
"traits": [
"Strong top rule",
"Ranked charts",
"Accent used sparingly",
"Confidential footer"
],
"rule": "a strong top rule, the accent used sparingly for emphasis, direct ranked charts, a source/confidentiality line in the footer — blunt and commercial."
},
{
"id": "research-bureau",
"label": "Research Bureau",
"hint": "Report flow, big numerals",
"desc": "Reads like an institute report — chapter dividers and big numerals.",
"accent": "violet",
"traits": [
"Chapter dividers",
"Big numerals",
"Methodology note",
"Report-style flow"
],
"rule": "chapter navigation, full-bleed section dividers with a large numeral, a methodology note, report-style flow — reads like an institute report."
},
{
"id": "systems-blueprint",
"label": "Systems Blueprint",
"hint": "Boxes, swimlanes, diagrams",
"desc": "Boxes, swimlanes and diagrams — structure over prose.",
"accent": "default",
"traits": [
"Modular boxes",
"Swimlanes",
"Capability maps",
"Diagrams & tables"
],
"rule": "modular boxes, swimlanes and capability maps, more tables and diagrams, strong section labels — structure over prose."
},
{
"id": "executive-brief",
"label": "Executive Brief",
"hint": "Dense, readable board memo",
"desc": "A dense but readable board memo — strict hierarchy, summary up front.",
"accent": "default",
"traits": [
"Summary up front",
"Strict hierarchy",
"Dense but readable",
"Section dividers"
],
"rule": "a neutral, dense-but-readable memo; an executive summary up front; strict hierarchy and section dividers — board-memo discipline."
}
]
}
/**
* html → pptx · deck builder.
*
* Takes the slides measured by extract.mjs (text boxes in design pixels +
* a background JPEG/PNG data-URL per slide) and assembles a .pptx via
* pptxgenjs. Isomorphic: works in Node (CLI) and in the browser (web tool).
*/
import PptxGenJS from 'pptxgenjs'
const SLIDE_WIDTH_IN = 13.333
const round = (v, p) => Math.round(v * 10 ** p) / 10 ** p
/**
* DrawingML custGeom for a rectangle with INDEPENDENT corner radii (design px).
* pptxgenjs only writes a uniform `roundRect`; a shape clipped to just two
* corners of a rounded `overflow:hidden` ancestor (a stacked pill bar's end
* segment, a card's header band) needs per-corner rounding. Walks clockwise
* from the top edge; a zero-radius corner becomes a sharp `lnTo`. Arc start
* angles match extract.mjs's rounded-rect (270° top-right … 180° top-left), in
* 60000ths of a degree, clockwise on PPTX's y-down canvas.
*
* @param {number} wPx shape width in design px
* @param {number} hPx shape height in design px
* @param {[number,number,number,number]} corners radii [tl,tr,br,bl] in design px
*/
function roundedRectGeom(wPx, hPx, corners) {
const SC = 100
const W = Math.round(wPx * SC)
const H = Math.round(hPx * SC)
const C = (v) => Math.round(v * SC)
const lim = Math.min(wPx, hPx) / 2
const [tl, tr, br, bl] = corners.map((c) => Math.max(0, Math.min(c, lim)))
const arc = (rad, start) => `<a:arcTo wR="${C(rad)}" hR="${C(rad)}" stAng="${start * 60000}" swAng="${90 * 60000}"/>`
let p = `<a:moveTo><a:pt x="${C(tl)}" y="0"/></a:moveTo>`
p += `<a:lnTo><a:pt x="${C(wPx - tr)}" y="0"/></a:lnTo>`
p += tr > 0 ? arc(tr, 270) : `<a:lnTo><a:pt x="${W}" y="0"/></a:lnTo>`
p += `<a:lnTo><a:pt x="${W}" y="${C(hPx - br)}"/></a:lnTo>`
p += br > 0 ? arc(br, 0) : `<a:lnTo><a:pt x="${W}" y="${H}"/></a:lnTo>`
p += `<a:lnTo><a:pt x="${C(bl)}" y="${H}"/></a:lnTo>`
p += bl > 0 ? arc(bl, 90) : `<a:lnTo><a:pt x="0" y="${H}"/></a:lnTo>`
p += `<a:lnTo><a:pt x="0" y="${C(tl)}"/></a:lnTo>`
p += tl > 0 ? arc(tl, 180) : `<a:lnTo><a:pt x="0" y="0"/></a:lnTo>`
p += '<a:close/>'
return `<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:rect l="0" t="0" r="${W}" b="${H}"/><a:pathLst><a:path w="${W}" h="${H}">${p}</a:path></a:pathLst></a:custGeom>`
}
const toB64 = (s) =>
typeof Buffer !== 'undefined'
? Buffer.from(s, 'utf8').toString('base64')
: btoa(unescape(encodeURIComponent(s)))
const hexRGB = (h) => [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
const luminance = (h) => {
const [r, g, b] = hexRGB(h)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
const saturation = (h) => {
const [r, g, b] = hexRGB(h).map((v) => v / 255)
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
return max === 0 ? 0 : (max - min) / max
}
// Absolute chroma (max-min over 0-255). HSV saturation alone over-rates a near-
// neutral DARK colour — a warm ink like #211e18 has saturation 0.27 but chroma
// only 9 — so the most-used ink can wrongly win accent1 over the real brand hue.
// A chroma floor separates true accents (chroma ~60-150) from inks (~5-23).
const chroma = (h) => {
const [r, g, b] = hexRGB(h)
return Math.max(r, g, b) - Math.min(r, g, b)
}
const ACCENT_CHROMA_MIN = 36
const norm6 = (h) => (typeof h === 'string' && /^#?[0-9a-fA-F]{6}$/.test(h) ? h.replace('#', '').toUpperCase() : null)
// ── Font substitution ──────────────────────────────────────────────────────
// We bake the typeface name into every text run, so a font the viewer's machine
// doesn't have shows whatever PowerPoint picks — not the deck's design, and not
// the CSS fallback chain (PowerPoint never sees it). The fix is substitution,
// not embedding (Windows PowerPoint rejects hand-built embedded fonts — see
// ROADMAP Phase 5): map every primary family the deck uses to a font that is
// safely present wherever PowerPoint runs.
//
// SAFE = the cross-platform web-core fonts (present on Windows + macOS) plus the
// fonts Microsoft Office itself ships (so they exist wherever PowerPoint runs —
// on both Windows and Mac). Deliberately excludes single-platform fonts like
// Helvetica/Helvetica Neue (Mac-only) and Segoe UI (Windows-only); those are
// routed to a universal equivalent via FONT_ALIASES instead.
const SAFE_FONTS = new Set(
[
// web-core (present on Windows + macOS)
'Arial', 'Arial Black', 'Verdana', 'Tahoma', 'Trebuchet MS', 'Georgia',
'Times New Roman', 'Courier New', 'Comic Sans MS', 'Impact',
// bundled with Microsoft Office (hence present wherever PowerPoint runs)
'Calibri', 'Calibri Light', 'Cambria', 'Candara', 'Consolas', 'Constantia',
'Corbel', 'Book Antiqua', 'Palatino Linotype',
].map((f) => f.toLowerCase()),
)
// Per-generic safe defaults, used when nothing in the author's stack is safe.
const GENERIC_DEFAULT = {
serif: 'Georgia',
'sans-serif': 'Arial',
monospace: 'Courier New',
cursive: 'Comic Sans MS',
fantasy: 'Impact',
'system-ui': 'Arial',
'ui-sans-serif': 'Arial',
'ui-serif': 'Georgia',
'ui-monospace': 'Consolas',
}
// Hand-picked matches for popular web fonts — a nicer substitute than the bare
// generic fallback. Only consulted when the author's own stack offers no safe
// font. Keyed lowercase.
const FONT_ALIASES = {
// sans
inter: 'Arial', roboto: 'Arial', 'open sans': 'Arial', 'noto sans': 'Arial',
lato: 'Calibri', montserrat: 'Calibri', poppins: 'Calibri', 'work sans': 'Calibri',
'source sans pro': 'Calibri', 'source sans 3': 'Calibri', nunito: 'Calibri',
'nunito sans': 'Calibri', rubik: 'Calibri', manrope: 'Calibri', 'dm sans': 'Calibri',
'pt sans': 'Tahoma', 'segoe ui': 'Arial', 'sf pro': 'Arial',
'sf pro text': 'Arial', 'sf pro display': 'Arial', '-apple-system': 'Arial',
'blinkmacsystemfont': 'Arial', 'ibm plex sans': 'Calibri', figtree: 'Calibri',
helvetica: 'Arial', 'helvetica neue': 'Arial',
// serif
'iowan old style': 'Georgia', merriweather: 'Georgia', 'playfair display': 'Cambria',
lora: 'Georgia', 'pt serif': 'Georgia', 'source serif pro': 'Cambria',
'source serif 4': 'Cambria', charter: 'Georgia', tinos: 'Times New Roman',
'crimson text': 'Georgia', 'crimson pro': 'Georgia', 'noto serif': 'Georgia',
'libre baskerville': 'Cambria', 'eb garamond': 'Book Antiqua', garamond: 'Book Antiqua',
'dm serif display': 'Cambria',
'iowan': 'Georgia', palatino: 'Palatino Linotype',
// mono
'jetbrains mono': 'Consolas', 'fira code': 'Consolas', 'fira mono': 'Consolas',
'source code pro': 'Consolas', 'ibm plex mono': 'Consolas', 'roboto mono': 'Consolas',
'sf mono': 'Consolas', menlo: 'Consolas', monaco: 'Consolas', 'space mono': 'Consolas',
'ui-monospace': 'Consolas',
}
const GENERICS = new Set(Object.keys(GENERIC_DEFAULT))
const isSafe = (f) => !!f && SAFE_FONTS.has(f.toLowerCase())
/**
* Resolve one font stack to a safe substitute, in priority order:
* 1. the primary family is itself safe → keep it (no substitution)
* 2. the first SAFE font in the author's own declared stack ("closest
* installed" — honours the fallback they chose, the template idiom)
* 3. a hand-picked alias for a known web font
* 4. the generic keyword's default (the trailing serif/sans-serif/…)
* 5. Arial, as a last resort
* Returns null when no substitution is needed (so the map stays minimal).
*/
function resolveFont(stack) {
const primary = stack[0]
if (!primary || isSafe(primary)) return null
for (const f of stack.slice(1)) {
if (isSafe(f)) return f
const g = f.toLowerCase()
if (GENERICS.has(g)) return GENERIC_DEFAULT[g]
}
const alias = FONT_ALIASES[primary.toLowerCase()]
if (alias) return alias
// name heuristic for unknown fonts with no generic in the stack
const p = primary.toLowerCase()
if (/(^|\s)(mono|code|consol)/.test(p)) return 'Courier New'
if (/serif/.test(p) && !/sans/.test(p)) return 'Georgia'
return 'Arial'
}
/**
* Build the default font map from every distinct stack the deck used, then let
* the user's explicit overrides win. Result keys are the primary families as
* baked into the runs; build/patch look each run's `fontFace` up here.
* @param {Record<string,string[]>} fontStacks primary family → declared stack
* @param {Record<string,string>} userMap explicit --font overrides
* @param {string[]} keep families to leave untouched (e.g. embedded fonts)
*/
export function buildFontMap(fontStacks = {}, userMap = {}, keep = []) {
const keepSet = new Set(keep.map((f) => f.toLowerCase()))
const map = {}
for (const [primary, stack] of Object.entries(fontStacks)) {
if (keepSet.has(primary.toLowerCase())) continue
const sub = resolveFont(stack)
if (sub && sub.toLowerCase() !== primary.toLowerCase()) map[primary] = sub
}
return { ...map, ...userMap }
}
/**
* Derive a brand theme from the measured deck: a light/dark pair from the
* dominant background and text, accent1-6 from the vivid colors the deck
* actually uses (chart series, shape fills, bullet markers, colored text),
* and major/minor fonts from the heading vs body runs. Written into
* theme1.xml so a shape or text box the user inserts in PowerPoint defaults
* to the deck's brand instead of Office blue. Best-effort: missing slots keep
* the Office defaults.
*/
function deriveTheme(slides, fontMap) {
const bump = (map, key, by = 1) => key && map.set(key, (map.get(key) || 0) + by)
const byCount = (map) => [...map.entries()].sort((a, b) => b[1] - a[1]).map(([k]) => k)
const bg = new Map()
const text = new Map()
const vivid = new Map()
const headFonts = new Map()
const bodyFonts = new Map()
for (const s of slides) {
bump(bg, norm6(s.bgColor))
for (const b of s.boxes || [])
for (const r of b.runs || []) {
const c = norm6(r.color)
bump(text, c, (r.text || '').length || 1)
if (c && saturation(c) > 0.25 && chroma(c) >= ACCENT_CHROMA_MIN && luminance(c) > 24 && luminance(c) < 232) bump(vivid, c, (r.text || '').length || 1)
const face = fontMap[r.fontFace] || r.fontFace
if (face) bump((r.fontSizePx || 0) >= 28 ? headFonts : bodyFonts, face)
}
for (const sh of s.shapes || [])
if (sh.fill && sh.fill.type === 'solid') {
const c = norm6(sh.fill.color)
if (c && saturation(c) > 0.25 && chroma(c) >= ACCENT_CHROMA_MIN && luminance(c) > 24 && luminance(c) < 232) bump(vivid, c, 3)
}
for (const L of s.lists || [])
for (const p of L.paras || [])
if (p.bullet && p.bullet.color) {
const c = norm6(p.bullet.color)
if (c && saturation(c) > 0.25 && chroma(c) >= ACCENT_CHROMA_MIN) bump(vivid, c, 2)
}
for (const C of s.charts || []) {
for (const sr of C.series || []) bump(vivid, norm6(sr.color), 4)
for (const c of C.colors || []) bump(vivid, norm6(c), 4)
}
}
const bgTop = byCount(bg)[0] || null
const textTop = byCount(text)[0] || null
const accents = byCount(vivid).slice(0, 6)
const head = byCount(headFonts)[0] || byCount(bodyFonts)[0] || null
const body = byCount(bodyFonts)[0] || head
// Light/dark pair from the deck's own bg + dominant text, so inserted text
// and shapes read sensibly regardless of whether the deck is dark-themed.
let lt1 = null
let dk1 = null
if (bgTop && textTop) {
const lighter = luminance(bgTop) >= luminance(textTop) ? bgTop : textTop
const darker = lighter === bgTop ? textTop : bgTop
lt1 = lighter
dk1 = darker
} else {
lt1 = bgTop
dk1 = textTop
}
if (!accents.length && !lt1 && !dk1 && !head) return null
return { lt1, dk1, accents, headFont: head, bodyFont: body }
}
/**
* @param {{
* wPx: number, hPx: number, title?: string, author?: string,
* fontMap?: Record<string, string>, fontStacks?: Record<string, string[]>, keepFonts?: string[],
* meta?: { brand?: string, foot?: string, ref?: string }, toolkit?: boolean,
* slides: Array<{ bg?: string, boxes: Array<any>, shapes?: Array<any>, svgs?: Array<any> }>,
* }} deck
* @returns {{ pptx: PptxGenJS, gradients: Record<string, any>, bulletColors: Record<string, any>, customGeom: Record<string, any>, connectors: Record<string, any>, report: Array<any>, theme: any }}
* `gradients` maps tagged shape names to gradient specs, `bulletColors` maps
* tagged list boxes to per-paragraph marker colors, `customGeom` maps tagged
* placeholder shapes to DrawingML custGeom XML, `connectors` maps tagged
* placeholder lines (h2px-N) to their cxnSp rewrite (endpoint names + elbow/
* arrow) — pptxgenjs can't write any of these, so patchPptx() fixes them in
* the XML. `report` is the per-slide conversion tally. `theme` is the
* deck-derived clrScheme/fontScheme override (or null), also applied by patch.
*/
// ── The layout toolkit (founder direction 2026-07-17) ───────────────────────
// Every converted deck ships a set of slide LAYOUTS that no visible slide
// uses: they cost nothing on screen (visible slides keep the DEFAULT layout,
// so their XML is byte-identical with or without the toolkit — the pixel
// harness cannot regress by construction) but they live in PowerPoint's
// Insert→New Slide gallery, so whoever works with the file later gets
// on-brand dividers, a blank, a closing and title options for free. Skinned
// from the deck itself: deriveTheme's palette + fonts, footer meta from the
// deck's <html data-*> attributes. Deliberately flat — solid fills, hairlines
// and text placeholders only; no images, nothing heavy (per the founder:
// "simple, corporate standard, invisible").
// Blend two hex colors (t=0 → a, t=1 → b) for muted inks and hairlines.
function mixHex(a, b, t) {
const ch = (h, i) => parseInt(h.slice(i, i + 2), 16)
const to2 = (n) => Math.round(n).toString(16).padStart(2, '0')
return (
to2(ch(a, 0) + (ch(b, 0) - ch(a, 0)) * t) +
to2(ch(a, 2) + (ch(b, 2) - ch(a, 2)) * t) +
to2(ch(a, 4) + (ch(b, 4) - ch(a, 4)) * t)
).toUpperCase()
}
export const TOOLKIT_LAYOUTS = ['Title', 'Title (dark)', 'Section divider', 'Section divider (dark)', 'Title and content', 'Blank', 'Closing']
export function defineLayoutToolkit(pptx, theme, meta = {}) {
const paper = theme?.lt1 || 'FFFFFF'
const ink = theme?.dk1 || '1F2937'
const accent = theme?.accents?.[0] || null
const head = theme?.headFont
const body = theme?.bodyFont
const brand = (meta.brand || '').trim()
const foot = (meta.foot || '').trim()
const ref = (meta.ref || '').trim()
// One palette per side: pal(false) draws on paper, pal(true) on ink.
const pal = (dark) => {
const bg = dark ? ink : paper
const fg = dark ? paper : ink
return { bg, fg, muted: mixHex(fg, bg, 0.4), hairline: mixHex(fg, bg, 0.82) }
}
const font = (face) => (face ? { fontFace: face } : {})
const ph = (name, type, opts, prompt) => ({ placeholder: { options: { name, type, ...opts }, text: prompt } })
const rule = (p, y) => (accent ? [{ rect: { x: '8%', y, w: '6%', h: 0.045, fill: { color: accent } } }] : [])
// The quiet footer line every content layout carries (skipped when the deck
// has no meta — non-unPaper input stays clean).
const footLine = (p) => {
const text = foot || brand
return text ? [{ text: { text, options: { x: '5%', y: '92.5%', w: '55%', h: '4.5%', fontSize: 9, color: p.muted, align: 'left', ...font(body) } } }] : []
}
const pageNo = (p) => ({ x: '87%', y: '92.5%', w: '8%', h: '4.5%', fontSize: 9, color: p.muted, align: 'right', ...font(body) })
const titleLayout = (dark) => {
const p = pal(dark)
pptx.defineSlideMaster({
title: dark ? 'Title (dark)' : 'Title',
background: { color: p.bg },
objects: [
...(brand ? [{ text: { text: brand, options: { x: '8%', y: '8%', w: '60%', h: '5%', fontSize: 13, bold: true, color: p.fg, align: 'left', ...font(head) } } }] : []),
...rule(p, '40%'),
ph('title', 'title', { x: '8%', y: '44%', w: '76%', h: '16%', fontSize: 40, bold: true, color: p.fg, align: 'left', ...font(head) }, 'Presentation title'),
ph('subtitle', 'body', { x: '8%', y: '62%', w: '68%', h: '9%', fontSize: 16, color: p.muted, align: 'left', ...font(body) }, 'Subtitle or date'),
...(ref ? [{ text: { text: ref, options: { x: '8%', y: '88%', w: '40%', h: '5%', fontSize: 10, color: p.muted, align: 'left', ...font(body) } } }] : []),
],
})
}
const dividerLayout = (dark) => {
const p = pal(dark)
pptx.defineSlideMaster({
title: dark ? 'Section divider (dark)' : 'Section divider',
background: { color: p.bg },
objects: [
...rule(p, '31%'),
ph('eyebrow', 'body', { x: '8%', y: '34%', w: '50%', h: '5%', fontSize: 12, bold: true, color: accent || p.muted, align: 'left', ...font(body) }, 'SECTION'),
ph('title', 'title', { x: '8%', y: '40%', w: '72%', h: '18%', fontSize: 34, bold: true, color: p.fg, align: 'left', ...font(head) }, 'Section title'),
...footLine(p),
],
slideNumber: pageNo(p),
})
}
titleLayout(false)
titleLayout(true)
dividerLayout(false)
dividerLayout(true)
const p = pal(false)
pptx.defineSlideMaster({
title: 'Title and content',
background: { color: p.bg },
objects: [
ph('title', 'title', { x: '5%', y: '6%', w: '90%', h: '9%', fontSize: 22, bold: true, color: p.fg, align: 'left', ...font(head) }, 'Slide title states the takeaway'),
{ rect: { x: '5%', y: '16.5%', w: '90%', h: 0.015, fill: { color: p.hairline } } },
ph('content', 'body', { x: '5%', y: '20%', w: '90%', h: '69%', fontSize: 14, color: p.fg, align: 'left', ...font(body) }, 'Content'),
...footLine(p),
],
slideNumber: pageNo(p),
})
pptx.defineSlideMaster({
title: 'Blank',
background: { color: p.bg },
objects: [...footLine(p)],
slideNumber: pageNo(p),
})
pptx.defineSlideMaster({
title: 'Closing',
background: { color: p.bg },
objects: [
...rule(p, '40%'),
ph('title', 'title', { x: '8%', y: '44%', w: '76%', h: '16%', fontSize: 40, bold: true, color: p.fg, align: 'left', ...font(head) }, 'Thank you'),
ph('contact', 'body', { x: '8%', y: '62%', w: '60%', h: '10%', fontSize: 14, color: p.muted, align: 'left', ...font(body) }, 'Contact · next step'),
],
})
}
// ── The .potx bridge (ROADMAP Phase 5 M3 / COMPANY-TEMPLATE-PLAN T7b) ───────
// A PowerPoint TEMPLATE from a measured deck: the layout toolkit skinned from
// the deck's own theme + meta, ONE starter slide on the Title layout, and no
// content — the company's PowerPoint-native starter file, generated without
// PowerPoint. The caller writes the pptx, applies patchPptx({ theme }) so
// inserted shapes default to the brand, then toPotx() (patch.mjs) to retype
// the file as a real template.
export function buildPotx({ wPx, hPx, title, fontStacks = {}, slides, meta = {} }) {
const fontMap = buildFontMap(fontStacks, {}, [])
const wIn = SLIDE_WIDTH_IN
const hIn = round((wIn * hPx) / wPx, 3)
const pptx = new PptxGenJS()
pptx.defineLayout({ name: 'H2P', width: wIn, height: hIn })
pptx.layout = 'H2P'
const theme = deriveTheme(slides, fontMap)
defineLayoutToolkit(pptx, theme, meta)
if (title) {
pptx.title = title
pptx.subject = title
}
pptx.author = 'unPaper'
pptx.company = 'unPaper · www.unpaper.ai'
// One starter slide so the template opens onto a ready cover (zero-slide
// files are legal OOXML but some consumers choke on them).
pptx.addSlide({ masterName: 'Title' })
return { pptx, theme }
}
export function buildPptx(deck) {
const { wPx, hPx, slides, title, author, fontMap: userFontMap = {}, fontStacks = {}, keepFonts = [], meta = {}, toolkit = true } = deck
// Default substitution (unsafe web fonts → installed equivalents), with the
// caller's explicit --font overrides layered on top. Embedded fonts (keepFonts)
// are left as-is — they ship inside the file, so they must keep their name.
const fontMap = buildFontMap(fontStacks, userFontMap, keepFonts)
const wIn = SLIDE_WIDTH_IN
const hIn = round((wIn * hPx) / wPx, 3)
const px2in = wIn / wPx
const px2pt = px2in * 72
const pptx = new PptxGenJS()
pptx.defineLayout({ name: 'H2P', width: wIn, height: hIn })
pptx.layout = 'H2P'
// Theme is needed up front now (the toolkit skins itself from it); the same
// object is returned for patch.mjs as before.
const theme = deriveTheme(slides, fontMap)
// The invisible layout toolkit: defined BEFORE any addSlide so the DEFAULT
// layout keeps its slot (toolkit: false / --no-toolkit restores a bare file).
if (toolkit) defineLayoutToolkit(pptx, theme, meta)
// M1 — shared backdrops become layouts: when ≥2 slides carry the byte-same
// backdrop capture, the image moves into ONE "Deck background N" layout and
// those slides reference it instead of embedding their own copy — the image
// ships once (file size) and the deck's real looks join the New Slide
// gallery. Slides with unique backdrops keep today's per-slide fill.
// Rendering is inheritance-identical (slide without bgPr → layout bg);
// the pixel harness is the gate.
const bgLayouts = new Map()
if (toolkit) {
const tally = new Map()
for (const s of slides) if (!(s.bgPlain && s.bgColor) && s.bg) tally.set(s.bg, (tally.get(s.bg) || 0) + 1)
let n = 0
for (const [bg, count] of tally)
if (count >= 2) {
n += 1
const name = `Deck background ${n}`
pptx.defineSlideMaster({ title: name, background: { data: bg }, objects: [] })
bgLayouts.set(bg, name)
}
}
if (title) {
pptx.title = title
pptx.subject = title
}
pptx.author = author || 'unPaper'
pptx.company = 'unPaper · www.unpaper.ai'
const gradients = {}
const bulletColors = {}
const customGeom = {}
const connectors = {}
let gradSeq = 0
let listSeq = 0
let customSeq = 0
let connSeq = 0
const alpha2transp = (a) => (a < 1 ? Math.round((1 - a) * 100) : undefined)
for (const s of slides) {
// A slide whose backdrop went into a shared layout references it and
// carries no bgPr of its own (background inherits layout → master).
const bgLayout = !(s.bgPlain && s.bgColor) && s.bg ? bgLayouts.get(s.bg) : undefined
const slide = bgLayout ? pptx.addSlide({ masterName: bgLayout }) : pptx.addSlide()
if (s.notes) slide.addNotes(s.notes)
// Background as the slide's NATIVE fill, never a movable picture: a flat
// slide becomes a native color (no image — white is just a white slide),
// everything else rides as a locked, unselectable background image fill.
if (s.bgPlain && s.bgColor) slide.background = { color: s.bgColor }
else if (s.bg && !bgLayout) slide.background = { data: s.bg }
// One z-sorted stream (DOM paint order measured by extract) instead of
// fixed layer buckets — an icon behind a later card stays behind it.
// Text boxes still come last: they must never end up under a shape.
const stream = [
...(s.shapes || []).map((o) => ({ kind: 'shape', o })),
...(s.svgs || []).map((o) => ({ kind: 'svg', o })),
...(s.imgs || []).map((o) => ({ kind: 'img', o })),
...(s.freeforms || []).map((o) => ({ kind: 'freeform', o })),
...(s.connectors || []).map((o) => ({ kind: 'connector', o })),
].sort((a, b) => (a.o.z ?? 0) - (b.o.z ?? 0))
for (const item of stream) {
if (item.kind === 'connector') {
// Native PPTX connector: a placeholder line tagged h2px-N whose sp
// block the patch pass rewrites into a real p:cxnSp (bentConnector3 /
// straightConnector1) with stCxn/endCxn bound to the endpoint shapes.
const c = item.o
const name = `h2px-${connSeq++}`
connectors[name] = { from: c.from, to: c.to, elbow: c.elbow, arrow: c.arrow }
slide.addShape('line', {
x: round(c.x * px2in, 3),
y: round(c.y * px2in, 3),
w: round(c.w * px2in, 3),
h: round(c.h * px2in, 3),
...(c.rot ? { rotate: c.rot } : {}),
flipH: c.flipH || undefined,
flipV: c.flipV || undefined,
line: {
color: c.stroke.hex,
width: Math.max(0.5, round(c.stroke.widthPx * px2pt, 2)),
transparency: alpha2transp(c.stroke.alpha),
dashType: c.stroke.dash ? 'dash' : undefined,
},
objectName: name,
})
continue
}
if (item.kind === 'freeform') {
// Native recolorable vector: a placeholder rect tagged h2pc-N, whose
// prstGeom the patch pass swaps for the real custGeom path. Filled
// icons get a solid fill + no line; stroked icons get no fill + a
// line matching the SVG stroke (colour and width).
const f = item.o
const name = `h2pc-${customSeq++}`
customGeom[name] = f.geom
const geo = {
x: round(f.x * px2in, 3),
y: round(f.y * px2in, 3),
w: round(f.w * px2in, 3),
h: round(f.h * px2in, 3),
}
const fLink = f.href ? { url: f.href } : undefined
if (f.stroke) {
slide.addShape('rect', {
...geo,
fill: { type: 'none' },
line: {
color: f.stroke.hex,
width: Math.max(0.5, round(f.stroke.widthPx * px2pt, 2)),
transparency: alpha2transp(f.stroke.alpha),
},
objectName: name,
hyperlink: fLink,
})
} else {
slide.addShape('rect', {
...geo,
fill: { color: f.fill.hex, transparency: alpha2transp(f.fill.alpha) },
line: { type: 'none' },
objectName: name,
hyperlink: fLink,
})
}
continue
}
if (item.kind === 'svg') {
const ic = item.o
slide.addImage({
data: 'data:image/svg+xml;base64,' + toB64(ic.svg),
x: round(ic.x * px2in, 3),
y: round(ic.y * px2in, 3),
w: round(ic.w * px2in, 3),
h: round(ic.h * px2in, 3),
})
continue
}
if (item.kind === 'img') {
const im = item.o
slide.addImage({
data: im.data,
x: round(im.x * px2in, 3),
y: round(im.y * px2in, 3),
w: round(im.w * px2in, 3),
h: round(im.h * px2in, 3),
hyperlink: im.href ? { url: im.href } : undefined,
})
continue
}
const sh = item.o
const geo = {
x: round(sh.x * px2in, 3),
y: round(sh.y * px2in, 3),
w: round(sh.w * px2in, 3),
h: round(sh.h * px2in, 3),
// Pure rotation about the shape centre (extract recovers it from a
// single-rotation transform); undefined for the common unrotated case.
...(sh.rot ? { rotate: sh.rot } : {}),
}
let fill
let objectName
if (sh.fill && (sh.fill.type === 'grad' || sh.fill.type === 'radial')) {
objectName = `h2pg-${gradSeq++}`
gradients[objectName] = sh.fill
fill = { color: sh.fill.stops[0].color, transparency: alpha2transp(sh.fill.stops[0].alpha) }
} else if (sh.fill) {
fill = { color: sh.fill.color, transparency: alpha2transp(sh.fill.alpha) }
}
// A connector endpoint carries its binding name — unless the name slot is
// already owned (gradient here, per-corner custGeom below): fidelity wins
// and the connector simply stays unbound (drawn right, not attached).
if (!objectName && sh.cxName && !sh.corners) objectName = sh.cxName
const line = sh.uniform
? {
color: sh.sides[0].color,
width: round(sh.sides[0].w * px2pt, 2),
transparency: alpha2transp(sh.sides[0].alpha),
dashType: sh.sides[0].dash,
}
: undefined
if (fill || line) {
// CSS spread has no DrawingML equivalent — folding it into blur is the
// closest visual match for the soft shadows decks actually use.
const shadow = sh.shadow
? {
type: sh.shadow.inset ? 'inner' : 'outer',
color: sh.shadow.color,
opacity: Math.round(sh.shadow.alpha * 100) / 100,
blur: round(Math.max(0, sh.shadow.blur + sh.shadow.spread) * px2pt, 1),
offset: round(Math.hypot(sh.shadow.x, sh.shadow.y) * px2pt, 1),
angle: Math.round(((Math.atan2(sh.shadow.y, sh.shadow.x) * 180) / Math.PI + 360) % 360),
}
: undefined
// Per-corner rounding (inherited from a rounded overflow-clip ancestor)
// can't be a uniform roundRect — emit a custGeom rounded rect via the
// h2pc placeholder + patch. A gradient fill already owns the objectName
// (h2pg), so it falls back to a uniform roundRect at the largest corner.
const perCorner = sh.corners && !objectName
const shLink = sh.href ? { url: sh.href } : undefined
if (perCorner) {
objectName = `h2pc-${customSeq++}`
customGeom[objectName] = roundedRectGeom(sh.w, sh.h, sh.corners)
slide.addShape('rect', { ...geo, fill, line, shadow, objectName, hyperlink: shLink })
} else {
const r = sh.corners ? Math.max(...sh.corners) : sh.radiusPx
const type = sh.circle ? 'ellipse' : r > 0.5 ? 'roundRect' : 'rect'
slide.addShape(type, {
...geo,
fill,
line,
shadow,
rectRadius: type === 'roundRect' ? round(Math.min(r, Math.min(sh.w, sh.h) / 2) * px2in, 3) : undefined,
objectName,
hyperlink: shLink,
})
}
}
if (!sh.uniform) {
// Per-side borders become thin bars — PPTX outlines are all-or-nothing.
const [t, rgt, btm, lft] = sh.sides
const bar = (bx, by, bw, bh, side) =>
slide.addShape('rect', {
x: round(bx * px2in, 3),
y: round(by * px2in, 3),
w: round(bw * px2in, 4),
h: round(bh * px2in, 4),
fill: { color: side.color, transparency: alpha2transp(side.alpha) },
})
if (t) bar(sh.x, sh.y, sh.w, t.w, t)
if (btm) bar(sh.x, sh.y + sh.h - btm.w, sh.w, btm.w, btm)
if (lft) bar(sh.x, sh.y, lft.w, sh.h, lft)
if (rgt) bar(sh.x + sh.w - rgt.w, sh.y, rgt.w, sh.h, rgt)
}
}
// Native charts (the data-h2p-chart contract): real, data-editable
// PowerPoint charts at the measured svg rect. The deck's own HTML
// legend/card stay through the normal passes, so legends are off and
// chart/plot areas transparent — the chart fills exactly the svg hole.
for (const C of s.charts || []) {
const geo = {
x: round(C.x * px2in, 3),
y: round(C.y * px2in, 3),
w: round(C.w * px2in, 3),
h: round(C.h * px2in, 3),
}
const circular = C.type === 'pie' || C.type === 'doughnut'
const data = circular
? [{ name: C.series[0].name, labels: C.categories, values: C.series[0].values }]
: C.series.map((sr) => ({ name: sr.name, labels: C.categories, values: sr.values }))
const palette = circular
? C.colors && C.colors.length
? C.colors
: C.series.map((sr) => sr.color).filter(Boolean)
: C.series.map((sr, i) => sr.color || ['4472C4', 'ED7D31', 'A5A5A5', 'FFC000', '5B9BD5'][i % 5])
const common = {
...geo,
chartColors: palette.length ? palette : undefined,
showTitle: false,
showLegend: false,
showValue: false,
// No border key: pptxgenjs treats ANY border object as truthy and fills
// in its defaults (1pt, 363636) — `{type:'none'}` is ignored and a solid
// line is emitted. Omitting border → pptxgenjs writes no <a:ln>, so the
// chart/plot areas render borderless (matching the HTML, which has none).
chartArea: { fill: { color: 'FFFFFF', transparency: 100 } },
plotArea: { fill: { color: 'FFFFFF', transparency: 100 } },
dataLabelColor: C.labelColor,
dataLabelFontFace: fontMap[C.fontFace] || C.fontFace,
}
const axes = circular
? {}
: {
catAxisLabelColor: C.labelColor,
catAxisLabelFontFace: fontMap[C.fontFace] || C.fontFace,
catAxisLabelFontSize: 9,
catAxisLineShow: false,
catGridLine: { style: 'none' },
valAxisLabelColor: C.labelColor,
valAxisLabelFontFace: fontMap[C.fontFace] || C.fontFace,
valAxisLabelFontSize: 9,
valAxisLineShow: false,
valGridLine: { style: 'none' },
...(C.axis && typeof C.axis.min === 'number' ? { valAxisMinVal: C.axis.min } : {}),
...(C.axis && typeof C.axis.max === 'number' ? { valAxisMaxVal: C.axis.max } : {}),
}
const extra =
C.type === 'doughnut'
? { holeSize: 70 }
: C.type === 'bar'
? { barDir: 'col', barGapWidthPct: 40 }
: C.type === 'line'
? { lineSize: 2, lineSmooth: false }
: {}
slide.addChart(C.type, data, { ...common, ...axes, ...extra })
}
// Native tables: measured grid geometry, cell fills/borders/padding,
// styled runs per cell. Native cells re-wrap their own text.
for (const T of s.tables || []) {
const rows = T.rows.map((row) =>
row.map((cell) => ({
text: cell.runs.length
? cell.runs.map((r) => ({
text: r.text,
options: {
fontFace: fontMap[r.fontFace] || r.fontFace,
fontSize: round(r.fontSizePx * px2pt, 1),
color: r.color,
bold: r.bold || undefined,
italic: r.italic || undefined,
underline: r.underline || undefined,
strike: r.strike || undefined,
subscript: r.sub || undefined,
superscript: r.sup || undefined,
transparency: r.alpha < 1 ? Math.round((1 - r.alpha) * 100) : undefined,
highlight: r.highlight || undefined,
breakLine: r.breakLine || undefined,
hyperlink: r.href ? { url: r.href } : undefined,
},
}))
: '',
options: {
fill: cell.fill ? { color: cell.fill.hex, transparency: alpha2transp(cell.fill.alpha) } : undefined,
align: cell.align,
valign: cell.valign,
colspan: cell.colspan,
rowspan: cell.rowspan,
margin: cell.padPx.map((p) => round(p * px2pt, 1)),
border: cell.sides.map((sd) =>
sd ? { type: 'solid', pt: round(sd.w * px2pt, 2), color: sd.color } : { type: 'none' },
),
},
})),
)
slide.addTable(rows, {
x: round(T.x * px2in, 3),
y: round(T.y * px2in, 3),
colW: T.colW.map((w) => round(w * px2in, 3)),
rowH: T.rowH.map((h) => round(h * px2in, 3)),
})
}
// Native bullet lists: one text box per list, one paragraph per <li>.
// Wrap stays ON here (a baked break would start a new bulleted
// paragraph), so the box gets generous width slack instead.
for (const L of s.lists || []) {
const runs = []
const colors = []
L.paras.forEach((p, pi) => {
p.runs.forEach((r, ri) => {
const o = {
fontFace: fontMap[r.fontFace] || r.fontFace,
fontSize: round(r.fontSizePx * px2pt, 1),
color: r.color,
bold: r.bold || undefined,
italic: r.italic || undefined,
underline: r.underline || undefined,
strike: r.strike || undefined,
subscript: r.sub || undefined,
superscript: r.sup || undefined,
charSpacing: r.charSpacingPx ? round(r.charSpacingPx * px2pt, 2) : undefined,
transparency: r.alpha < 1 ? Math.round((1 - r.alpha) * 100) : undefined,
highlight: r.highlight || undefined,
hyperlink: r.href ? { url: r.href } : undefined,
}
if (ri === 0) {
// Paragraph props ride ONLY on the first run: pptxgenjs writes a
// (spec-invalid) mid-paragraph <a:pPr> with <a:buNone/> for every
// run carrying any paragraph-level option — which kills the
// bullet. Same reason the body options below set no align or
// lineSpacing.
const indent = Math.max(8, round(L.indentPx * px2pt, 1))
o.bullet = p.bullet
? p.bullet.number
? { type: 'number', indent }
: { code: (p.bullet.char || '•').codePointAt(0).toString(16).toUpperCase().padStart(4, '0'), indent }
: false
o.paraSpaceAfter = p.spaceAfterPx ? round(p.spaceAfterPx * px2pt, 1) : undefined
o.lineSpacing = round(p.lineHeightPx * px2pt, 1)
}
if (ri === p.runs.length - 1 && pi < L.paras.length - 1) o.breakLine = true
runs.push({ text: r.text, options: o })
})
const bc = p.bullet && !p.bullet.number && p.bullet.color ? { hex: p.bullet.color, alpha: p.bullet.alpha ?? 1 } : null
colors.push(bc)
})
// Every list box gets tagged: the patch pass must always visit it to
// strip pptxgenjs's spec-invalid mid-paragraph <a:pPr><a:buNone/>
// blocks (emitted after every styled run; they kill the bullet), and
// to inject buClr where markers are colored.
const objectName = `h2pl-${listSeq++}`
bulletColors[objectName] = colors
const padX = Math.max(6, L.w * 0.06)
slide.addText(runs, {
x: round(L.x * px2in, 3),
y: round(L.y * px2in, 3),
w: round((L.w + padX) * px2in, 3),
h: round((L.h + Math.max(2, L.h * 0.06)) * px2in, 3),
valign: 'top',
margin: 0,
wrap: true,
objectName,
})
}
for (const b of s.boxes) {
// Per-paragraph lineSpacing: when font sizes differ across paragraphs the
// box-level lineSpacing (based on the container's own fontSize) is wrong
// for some paragraphs. Mirror the list-pass pattern: set lineSpacing on
// the FIRST run of each paragraph (pptxgenjs paragraph-level option),
// but only when paraLineHeights actually varies — uniform boxes skip it.
const paraLhs = b.paraLineHeights
const hasPerParaLh =
paraLhs &&
paraLhs.length > 1 &&
paraLhs.some((lh) => Math.abs(lh - b.lineHeightPx) >= 0.5)
let paraIdx = 0
let firstInPara = true
const runs = b.runs.map((r) => {
const options = {
fontFace: fontMap[r.fontFace] || r.fontFace,
fontSize: round(r.fontSizePx * px2pt, 1),
color: r.color,
bold: r.bold || undefined,
italic: r.italic || undefined,
underline: r.underline || undefined,
strike: r.strike || undefined,
subscript: r.sub || undefined,
superscript: r.sup || undefined,
charSpacing: r.charSpacingPx ? round(r.charSpacingPx * px2pt, 2) : undefined,
transparency: r.alpha < 1 ? Math.round((1 - r.alpha) * 100) : undefined,
highlight: r.highlight || undefined,
breakLine: r.breakLine || undefined,
hyperlink: r.href ? { url: r.href } : undefined,
}
if (firstInPara && hasPerParaLh) {
options.lineSpacing = round((paraLhs[paraIdx] ?? b.lineHeightPx) * px2pt, 1)
}
firstInPara = !!r.breakLine
if (r.breakLine) paraIdx++
return { text: r.text, options }
})
// Slack against font-metric drift between the browser and PowerPoint:
// a slightly wider box prevents premature wrapping of measured lines.
// wrap:true boxes reflow when PowerPoint's substituted font runs a touch
// wider than the browser's — the last word drops to a new line. They need
// a more generous cushion than wrap:false boxes, whose line breaks are
// baked and never reflow regardless of box width. But the cushion is
// CAPPED to ~1.8× the box's largest font: enough to absorb metric drift,
// never enough to pull a whole word UP a line or to push a near-full-width
// box off the slide edge (the % cushion alone added ~84px on a 1100px box,
// which both changed the wrap and hung the box past the slide).
const maxFontPx = b.runs.reduce((m, r) => Math.max(m, r.fontSizePx || 0), 0) || 12
const padX = b.wrapText
? Math.min(Math.max(12, b.w * 0.075), maxFontPx * 1.8)
: Math.max(3, b.w * 0.025)
const padY = Math.max(2, b.h * 0.08)
// A rotated box (pure rotation recovered in extract) keeps the slack
// symmetric so its centre — the rotation pivot — is preserved.
const centred = b.rot ? padX / 2 : b.align === 'right' ? padX : b.align === 'center' ? padX / 2 : 0
const x = b.x - centred
const y = b.rot ? b.y - padY / 2 : b.y
// text-shadow → native outer shadow on the text box (spread-less; same
// angle/offset/blur mapping as shape shadows).
const textShadow = b.shadow
? {
type: 'outer',
color: b.shadow.color,
opacity: Math.round(b.shadow.alpha * 100) / 100,
blur: round(Math.max(0, b.shadow.blur) * px2pt, 1),
offset: round(Math.hypot(b.shadow.x, b.shadow.y) * px2pt, 1),
angle: Math.round(((Math.atan2(b.shadow.y, b.shadow.x) * 180) / Math.PI + 360) % 360),
}
: undefined
slide.addText(runs, {
x: round(x * px2in, 3),
y: round(y * px2in, 3),
w: round((b.w + padX) * px2in, 3),
h: round((b.h + padY) * px2in, 3),
align: b.align,
valign: 'top',
margin: 0,
shadow: textShadow,
...(b.rot ? { rotate: b.rot } : {}),
lineSpacing: round(b.lineHeightPx * px2pt, 1),
// wrap:true → text reflows on resize (standard PowerPoint behaviour,
// word-wrap breaks removed in collectRuns; only <br>/block breaks kept).
// wrap:false → baked line breaks guarantee no reflow on font substitution.
wrap: !!b.wrapText,
})
}
}
// Per-slide conversion report: what landed as which kind of native object
// vs what stayed in the captured background. The data behind the trust
// feature ("a converter that tells you what it did"); CLI prints it, the
// web UI can surface it. Pure tally — no effect on the output file.
const report = slides.map((s, i) => {
const textBoxes = (s.boxes || []).length
const lists = (s.lists || []).length
const tables = (s.tables || []).length
const charts = (s.charts || []).length
const shapes = (s.shapes || []).length
const freeforms = (s.freeforms || []).length
const vectorPics = (s.svgs || []).length
const images = (s.imgs || []).length
// Native, editable objects (everything except the background capture).
const native = textBoxes + lists + tables + charts + shapes + freeforms + vectorPics + images
return {
slide: i + 1,
native,
textBoxes,
lists,
tables,
charts,
shapes,
freeforms,
vectorPics,
images,
notes: !!s.notes,
background: s.bgPlain && s.bgColor ? 'native-color' : s.bg ? 'image-fill' : 'none',
}
})
return { pptx, gradients, bulletColors, customGeom, connectors, report, theme }
}

Sorry, the diff of this file is too big to display

/**
* html → pptx · font introspection + EOT wrapping.
*
* Two jobs, both on raw .ttf/.otf bytes, both isomorphic (no Node/DOM APIs —
* the web tool reads a dropped font with the same code as the CLI):
*
* - readFontInfo() — family name, style bucket (regular/bold/italic/boldItalic)
* and OS/2 embedding permission, so the embedder can name the
* <p:font typeface> to match the deck's runs, slot the file into the right
* relationship, and warn when a licence forbids embedding.
* - toEot() — wrap a font in an Embedded OpenType (EOT) header. PowerPoint's
* ppt/fonts/*.fntdata parts are NOT raw TrueType: MS-OE376 §15.2.12 says
* PowerPoint stores fonts there in EOT ([Embed-Open-Type-Format]) and
* converts them back to TTF on open. A raw .ttf is silently ignored (the
* font flashes, then PowerPoint substitutes). We emit an uncompressed,
* non-subset EOT v1 — legal and the simplest form T2EMBED will load.
*/
const tag = (dv, off) =>
String.fromCharCode(dv.getUint8(off), dv.getUint8(off + 1), dv.getUint8(off + 2), dv.getUint8(off + 3))
function decodeName(dv, start, length, platformID) {
// Windows (3) and Unicode (0) name strings are UTF-16BE; Mac (1) is (for the
// Latin scripts we embed) effectively ASCII.
if (platformID === 3 || platformID === 0) {
let s = ''
for (let i = 0; i + 1 < length; i += 2) s += String.fromCharCode(dv.getUint16(start + i))
return s
}
let s = ''
for (let i = 0; i < length; i++) s += String.fromCharCode(dv.getUint8(start + i))
return s
}
/** Parse the table directory and the fields the embedder needs once, so
readFontInfo and toEot share a single pass over the bytes. */
function parseFont(input) {
const bytes = input instanceof Uint8Array ? input : new Uint8Array(input)
const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
let base = 0
if (dv.getUint32(0) === 0x74746366) base = dv.getUint32(12) // 'ttcf' → first font in a collection
const version = dv.getUint32(base)
const format = version === 0x4f54544f ? 'cff' : version === 0x00010000 || version === 0x74727565 ? 'truetype' : 'unknown'
const numTables = dv.getUint16(base + 4)
let nameOff = 0
let os2Off = 0
let os2Len = 0
let headOff = 0
for (let i = 0; i < numTables; i++) {
const rec = base + 12 + i * 16
const t = tag(dv, rec)
if (t === 'name') nameOff = dv.getUint32(rec + 8)
else if (t === 'OS/2') {
os2Off = dv.getUint32(rec + 8)
os2Len = dv.getUint32(rec + 12)
} else if (t === 'head') headOff = dv.getUint32(rec + 8)
}
// names: best record per nameID, preferring typographic ids and Windows/Unicode.
const names = {}
const rank = {}
if (nameOff) {
const count = dv.getUint16(nameOff + 2)
const storage = nameOff + dv.getUint16(nameOff + 4)
for (let i = 0; i < count; i++) {
const rec = nameOff + 6 + i * 12
const platformID = dv.getUint16(rec)
const nameID = dv.getUint16(rec + 6)
const len = dv.getUint16(rec + 8)
const off = dv.getUint16(rec + 10)
const platScore = platformID === 3 || platformID === 0 ? 1 : 0
if (names[nameID] != null && platScore <= (rank[nameID] ?? -1)) continue
const value = decodeName(dv, storage + off, len, platformID).replace(/\0/g, '').trim()
if (value) {
names[nameID] = value
rank[nameID] = platScore
}
}
}
return { bytes, dv, os2Off, os2Len, headOff, names, format }
}
const rdU16 = (dv, off, len, at) => (off && len >= at + 2 ? dv.getUint16(off + at) : 0)
const rdU32 = (dv, off, len, at) => (off && len >= at + 4 ? dv.getUint32(off + at) : 0)
/**
* @param {Uint8Array|ArrayBuffer|Buffer} input raw font file bytes
* @returns {{
* family: string|null, subfamily: string|null,
* bold: boolean, italic: boolean, style: 'regular'|'bold'|'italic'|'boldItalic',
* fsType: number, embeddable: boolean, format: 'truetype'|'cff'|'unknown',
* }}
*/
export function readFontInfo(input) {
const { dv, os2Off, os2Len, names, format } = parseFont(input)
const fsType = rdU16(dv, os2Off, os2Len, 8)
const fsSelection = rdU16(dv, os2Off, os2Len, 62)
const family = names[16] || names[1] || null
const subfamily = names[17] || names[2] || null
const sub = subfamily || ''
const bold = !!(fsSelection & 0x20) || /\bbold\b/i.test(sub)
const italic = !!(fsSelection & 0x01) || /\b(italic|oblique)\b/i.test(sub)
const style = bold && italic ? 'boldItalic' : bold ? 'bold' : italic ? 'italic' : 'regular'
// fsType bits 0–3 are the embedding licence; 0x0002 = "restricted, no embed".
const embeddable = (fsType & 0x000f) !== 0x0002
return { family, subfamily, bold, italic, style, fsType, embeddable, format }
}
/**
* Wrap raw font bytes in an uncompressed EOT v1 so PowerPoint accepts the part.
* The header mirrors the font's OS/2 + head + name tables; the font itself is
* appended verbatim (no MTX compression, no subsetting → Flags = 0).
* @param {Uint8Array|ArrayBuffer|Buffer} input
* @returns {Uint8Array} the .fntdata payload
*/
export function toEot(input) {
const { bytes, dv, os2Off, os2Len, headOff, names } = parseFont(input)
const utf16le = (s) => {
const b = new Uint8Array(s.length * 2)
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
b[i * 2] = c & 0xff
b[i * 2 + 1] = c >> 8
}
return b
}
const family = utf16le(names[16] || names[1] || '')
const styleName = utf16le(names[17] || names[2] || 'Regular')
const versionName = utf16le(names[5] || '')
const fullName = utf16le(names[4] || '')
const weight = rdU16(dv, os2Off, os2Len, 4) || 400
const fsType = rdU16(dv, os2Off, os2Len, 8)
const fsSelection = rdU16(dv, os2Off, os2Len, 62)
const italic = fsSelection & 0x01 ? 1 : 0
const unicodeRange = [42, 46, 50, 54].map((at) => rdU32(dv, os2Off, os2Len, at))
const codePageRange = [78, 82].map((at) => rdU32(dv, os2Off, os2Len, at))
const checkSumAdjustment = headOff ? dv.getUint32(headOff + 8) : 0
const panose = new Uint8Array(10)
if (os2Off && os2Len >= 32 + 10) for (let i = 0; i < 10; i++) panose[i] = dv.getUint8(os2Off + 32 + i)
// Header size: fixed prefix (82) + PANOSE(10) already counted below, plus the
// four length-prefixed names each with a 2-byte leading padding.
const fixed = 82 // bytes from EOTSize through Reserved4 + Padding1, see writes below
const namesBytes = 2 + family.length + 2 + 2 + styleName.length + 2 + 2 + versionName.length + 2 + 2 + fullName.length
const headerSize = fixed + namesBytes
const out = new Uint8Array(headerSize + bytes.length)
const o = new DataView(out.buffer)
let p = 0
const u32 = (v) => {
o.setUint32(p, v >>> 0, true)
p += 4
}
const u16 = (v) => {
o.setUint16(p, v & 0xffff, true)
p += 2
}
const u8 = (v) => {
o.setUint8(p, v & 0xff)
p += 1
}
const blob = (b) => {
out.set(b, p)
p += b.length
}
u32(out.length) // EOTSize (total)
u32(bytes.length) // FontDataSize
u32(0x00010000) // Version 1
u32(0) // Flags: uncompressed, non-subset
blob(panose) // FontPANOSE[10]
u8(1) // Charset = DEFAULT_CHARSET
u8(italic) // Italic
u32(weight) // Weight
u16(fsType) // fsType (embedding permissions)
u16(0x504c) // MagicNumber 'LP'
unicodeRange.forEach(u32) // UnicodeRange1..4
codePageRange.forEach(u32) // CodePageRange1..2
u32(checkSumAdjustment) // CheckSumAdjustment
u32(0) // Reserved1
u32(0) // Reserved2
u32(0) // Reserved3
u32(0) // Reserved4
u16(0) // Padding1
u16(family.length)
blob(family)
u16(0) // Padding2
u16(styleName.length)
blob(styleName)
u16(0) // Padding3
u16(versionName.length)
blob(versionName)
u16(0) // Padding4
u16(fullName.length)
blob(fullName)
blob(bytes) // FontData (verbatim)
return out
}
/**
* html → pptx · post-processing.
*
* pptxgenjs has no gradient-fill API, so buildPptx tags gradient shapes by
* objectName (h2pg-N) and fills them with a solid placeholder. This module
* reopens the finished .pptx (a zip) and swaps those placeholders for real
* DrawingML <a:gradFill> definitions. Isomorphic via jszip.
*/
import JSZip from 'jszip'
import { toEot } from './fonts.mjs'
function gradXml(g) {
const stops = [...g.stops]
.sort((a, b) => a.pos - b.pos)
.map((st) => {
const alpha = st.alpha < 1 ? `<a:alpha val="${Math.round(st.alpha * 100000)}"/>` : ''
return `<a:gs pos="${Math.round(st.pos * 1000)}"><a:srgbClr val="${st.color}">${alpha}</a:srgbClr></a:gs>`
})
.join('')
if (g.type === 'radial') {
// Circular path gradient; gs pos 0 = focus (CSS center), 100 = edge.
// fillToRect places the focus point (a degenerate rect) at focus%.
const f = g.focus || { x: 50, y: 50 }
const l = Math.round(f.x * 1000)
const t = Math.round(f.y * 1000)
const r = Math.round((100 - f.x) * 1000)
const b = Math.round((100 - f.y) * 1000)
return `<a:gradFill rotWithShape="1"><a:gsLst>${stops}</a:gsLst><a:path path="circle"><a:fillToRect l="${l}" t="${t}" r="${r}" b="${b}"/></a:path></a:gradFill>`
}
const ang = Math.round(g.angle * 60000)
return `<a:gradFill rotWithShape="1"><a:gsLst>${stops}</a:gsLst><a:lin ang="${ang}" scaled="0"/></a:gradFill>`
}
/** buClr per bulleted paragraph — pptxgenjs has no bullet-color API, so the
bullet would inherit the first run's text color; decks color their
markers (accent dots, green/red pros-cons) so we inject the real color.
buClr must precede buSzPct, buFont and buChar inside <a:pPr>. */
function injectBulletColors(block, colors) {
// pptxgenjs emits a (spec-invalid) <a:pPr><a:buNone/></a:pPr> after every
// styled run inside a paragraph; renderers honor the buNone and drop the
// bullet. pPr is only legal as a paragraph's first child — strip the rest.
block = block
.replace(/(<\/a:r>)<a:pPr\b[^>]*\/>/g, '$1')
.replace(/(<\/a:r>)<a:pPr\b[^>]*>[\s\S]*?<\/a:pPr>/g, '$1')
let i = 0
return block.replace(/<a:pPr\b[^>]*>[\s\S]*?<\/a:pPr>/g, (ppr) => {
if (!/<a:bu(Char|AutoNum)/.test(ppr)) return ppr
const c = colors[Math.min(i++, colors.length - 1)]
if (!c) return ppr
const clr =
c.alpha < 1
? `<a:buClr><a:srgbClr val="${c.hex}"><a:alpha val="${Math.round(c.alpha * 100000)}"/></a:srgbClr></a:buClr>`
: `<a:buClr><a:srgbClr val="${c.hex}"/></a:buClr>`
return ppr.replace(/<a:bu(SzPct|SzPts|Font|Char|AutoNum)/, clr + '<a:bu$1')
})
}
/** Rewrite theme1.xml's color + font schemes with the deck's derived brand,
so PowerPoint's color picker and "New Slide" layouts speak the deck's
palette and fonts. Missing slots keep the Office defaults. */
function patchTheme(xml, theme) {
let out = xml
const srgb = (slot, hex) =>
(out = out.replace(
new RegExp(`(<a:${slot}>)<a:(?:srgbClr|sysClr)[^>]*?/>(</a:${slot}>)`),
`$1<a:srgbClr val="${hex}"/>$2`,
))
if (theme.lt1) srgb('lt1', theme.lt1)
if (theme.dk1) srgb('dk1', theme.dk1)
;(theme.accents || []).forEach((hex, i) => hex && srgb(`accent${i + 1}`, hex))
if (theme.accents && theme.accents[0]) srgb('hlink', theme.accents[0])
// Fonts: only the primary latin typeface of major/minor (leave script fonts).
if (theme.headFont)
out = out.replace(/(<a:majorFont><a:latin typeface=")[^"]*(")/, `$1${theme.headFont}$2`)
if (theme.bodyFont)
out = out.replace(/(<a:minorFont><a:latin typeface=")[^"]*(")/, `$1${theme.bodyFont}$2`)
return out
}
const xmlEsc = (s) =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c])
/**
* Embed font files in the deck (zip-level — pptxgenjs has no API). Four parts
* conspire and ALL are required or PowerPoint declares the file corrupt:
* 1. ppt/fonts/fontN.fntdata — the raw font bytes, one part per style
* 2. [Content_Types].xml — a Default for the `fntdata` extension
* (`application/x-fontdata`); MISS THIS and the file won't open
* 3. ppt/presentation.xml — `embedTrueTypeFonts="1"` on the root +
* a <p:embeddedFontLst> (must sit after <p:notesSz>, before defaultTextStyle)
* 4. ppt/_rels/presentation.xml.rels — a `/font` relationship per part
* The <p:font typeface> must match the run's resolved typeface for PowerPoint to
* actually use the embedded bytes; the caller names each font accordingly.
*
* @param {JSZip} zip
* @param {Array<{ family: string, regular?: Uint8Array, bold?: Uint8Array, italic?: Uint8Array, boldItalic?: Uint8Array }>} fonts
*/
async function embedFonts(zip, fonts) {
const usable = fonts.filter((f) => f && f.family && (f.regular || f.bold || f.italic || f.boldItalic))
if (!usable.length) return
let rels = await zip.file('ppt/_rels/presentation.xml.rels').async('string')
let pres = await zip.file('ppt/presentation.xml').async('string')
let ct = await zip.file('[Content_Types].xml').async('string')
// rIds must not collide with the ones pptxgenjs already wrote.
let maxRid = 0
for (const m of rels.matchAll(/Id="rId(\d+)"/g)) maxRid = Math.max(maxRid, +m[1])
let partN = 0
const relAdds = []
const fontEntries = []
const FONT_REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/font'
for (const f of usable) {
const styleTags = []
for (const style of ['regular', 'bold', 'italic', 'boldItalic']) {
const buf = f[style]
if (!buf) continue
partN++
const rid = `rId${++maxRid}`
const part = `ppt/fonts/font${partN}.fntdata`
// PowerPoint's font parts are EOT, not raw TTF (MS-OE376 §15.2.12).
zip.file(part, toEot(buf))
relAdds.push(`<Relationship Id="${rid}" Type="${FONT_REL}" Target="fonts/font${partN}.fntdata"/>`)
styleTags.push(`<p:${style} r:id="${rid}"/>`)
}
fontEntries.push(`<p:embeddedFont><p:font typeface="${xmlEsc(f.family)}"/>${styleTags.join('')}</p:embeddedFont>`)
}
// 1+4. font parts written above; splice the relationships in.
rels = rels.replace('</Relationships>', relAdds.join('') + '</Relationships>')
// 2. content-type default for the font parts (idempotent).
if (!/Extension="fntdata"/.test(ct)) {
ct = ct.replace('</Types>', '<Default Extension="fntdata" ContentType="application/x-fontdata"/></Types>')
}
// 3. root flag + the embedded-font list. pptxgenjs ships saveSubsetFonts="1";
// we embed full files, so declare them un-subset and turn embedding on.
pres = pres.replace(
/(<p:presentation\b[^>]*?)\s*saveSubsetFonts="1"/,
'$1 embedTrueTypeFonts="1" saveSubsetFonts="0"',
)
if (!/embedTrueTypeFonts=/.test(pres)) {
pres = pres.replace(/<p:presentation\b/, '<p:presentation embedTrueTypeFonts="1"')
}
const lst = `<p:embeddedFontLst>${fontEntries.join('')}</p:embeddedFontLst>`
pres = pres.replace(/(<p:notesSz\b[^>]*\/>)/, `$1${lst}`)
zip.file('ppt/_rels/presentation.xml.rels', rels)
zip.file('ppt/presentation.xml', pres)
zip.file('[Content_Types].xml', ct)
}
/**
* @param {Blob|ArrayBuffer|Uint8Array|Buffer} data the written .pptx
* @param {{
* gradients?: Record<string, any>,
* bulletColors?: Record<string, Array<{hex: string, alpha: number}|null>>,
* customGeom?: Record<string, string>,
* theme?: { lt1?: string, dk1?: string, accents?: string[], headFont?: string, bodyFont?: string },
* svgFallbacks?: Record<number, string[]>,
* fonts?: Array<{ family: string, regular?: Uint8Array, bold?: Uint8Array, italic?: Uint8Array, boldItalic?: Uint8Array }>,
* }} fixes
* gradients — objectName → gradient spec
* bulletColors — objectName (h2pl-N) → per-paragraph marker colors
* customGeom — objectName (h2pc-N) → DrawingML custGeom XML for freeforms
* theme — deck-derived clrScheme/fontScheme overrides for theme1.xml
* svgFallbacks — slide number (1-based) → PNG data-URLs in the order the
* slide's SVG objects were added. In Node pptxgenjs stamps a
* broken-image placeholder as the svgBlip PNG fallback; this
* swaps in real previews (browser builds don't need it).
* @param {'nodebuffer'|'blob'} outputType
*/
// ED-5/E7.2: carry the deck's data-unpaper-provenance stamp into the .pptx as
// the CUSTOM document property `unpaperProvenance` (raw JSON string, verbatim —
// no re-serialization drift). pptxgenjs has no custom-props API, so this is
// zip-level OOXML: the part itself, the [Content_Types].xml override, AND the
// package-rels entry — all three, or PowerPoint offers to "repair" the file.
// Core props (title/author) are untouched.
function writeProvenanceProperty(zip, value) {
const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
zip.file(
'docProps/custom.xml',
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" ` +
`xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">` +
`<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="2" name="unpaperProvenance">` +
`<vt:lpwstr>${esc(value)}</vt:lpwstr></property></Properties>`,
)
return Promise.all([
zip.file('[Content_Types].xml').async('string').then((ct) => {
if (!ct.includes('docProps/custom.xml')) {
zip.file('[Content_Types].xml', ct.replace(
'</Types>',
'<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/></Types>',
))
}
}),
zip.file('_rels/.rels').async('string').then((rels) => {
if (!rels.includes('docProps/custom.xml')) {
zip.file('_rels/.rels', rels.replace(
'</Relationships>',
'<Relationship Id="rIdUnpaperProv" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/></Relationships>',
))
}
}),
])
}
export async function patchPptx(data, fixes, outputType) {
const { gradients = {}, bulletColors = {}, customGeom = {}, connectors = {}, theme = null, svgFallbacks = {}, fonts = [], provenance = '' } = fixes
const zip = await JSZip.loadAsync(data)
if (fonts.length) await embedFonts(zip, fonts)
if (provenance) await writeProvenanceProperty(zip, provenance)
if (theme) {
for (const name of Object.keys(zip.files).filter((n) => /^ppt\/theme\/theme\d+\.xml$/.test(n))) {
const xml = await zip.file(name).async('string')
const patched = patchTheme(xml, theme)
if (patched !== xml) zip.file(name, patched)
}
}
if (Object.keys(gradients).length || Object.keys(bulletColors).length || Object.keys(customGeom).length || Object.keys(connectors).length) {
const slideFiles = Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))
for (const name of slideFiles) {
const xml = await zip.file(name).async('string')
// Connector endpoints are bound by shape id — map this slide's shape
// names to their cNvPr ids before rewriting any blocks.
const shapeIds = {}
if (Object.keys(connectors).length) {
for (const im of xml.matchAll(/<p:cNvPr id="(\d+)" name="([^"]+)"/g)) shapeIds[im[2]] = im[1]
}
const patched = xml.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, (block) => {
const m = /name="(h2pg-\d+|h2pl-\d+|h2pc-\d+|h2px-\d+)"/.exec(block)
if (!m) return block
const g = gradients[m[1]]
if (g)
// First solidFill in spPr is the shape fill (the outline's comes later).
return block.replace(/<a:solidFill>[\s\S]*?<\/a:solidFill>/, gradXml(g))
const colors = bulletColors[m[1]]
if (colors) return injectBulletColors(block, colors)
const geom = customGeom[m[1]]
if (geom)
// Swap the placeholder rect's preset geometry for the real freeform.
return block.replace(/<a:prstGeom[^>]*>[\s\S]*?<\/a:prstGeom>|<a:prstGeom[^>]*\/>/, geom)
const cx = connectors[m[1]]
if (cx) {
// Rewrite the placeholder line into a REAL connector (p:cxnSp):
// stCxn/endCxn bind it to the endpoint shapes' ids, so PowerPoint
// re-routes the line when a box is dragged. A missing endpoint name
// (its objectName slot was owned by a gradient/custGeom) just means
// that end stays unbound — the line is still drawn correctly.
// cxnSp carries no txBody; strip the one pptxgenjs always emits.
const st = cx.from && shapeIds[cx.from.name] ? `<a:stCxn id="${shapeIds[cx.from.name]}" idx="${cx.from.idx}"/>` : ''
const en = cx.to && shapeIds[cx.to.name] ? `<a:endCxn id="${shapeIds[cx.to.name]}" idx="${cx.to.idx}"/>` : ''
let b = block
.replace('<p:sp>', '<p:cxnSp>')
.replace('</p:sp>', '</p:cxnSp>')
.replace('<p:nvSpPr>', '<p:nvCxnSpPr>')
.replace('</p:nvSpPr>', '</p:nvCxnSpPr>')
.replace(/<p:cNvSpPr\b[^>]*\/>|<p:cNvSpPr\b[^>]*>[\s\S]*?<\/p:cNvSpPr>/, `<p:cNvCxnSpPr>${st}${en}</p:cNvCxnSpPr>`)
.replace(/<a:prstGeom[^>]*>[\s\S]*?<\/a:prstGeom>|<a:prstGeom[^>]*\/>/, `<a:prstGeom prst="${cx.elbow ? 'bentConnector3' : 'straightConnector1'}"><a:avLst/></a:prstGeom>`)
.replace(/<p:txBody>[\s\S]*?<\/p:txBody>/, '')
if (cx.arrow) b = b.replace(/<\/a:ln>/, '<a:tailEnd type="triangle"/></a:ln>')
return b
}
return block
})
if (patched !== xml) zip.file(name, patched)
}
}
// pptxgenjs's addShape shadow path (dist pptxgen.cjs.js ~L5508) hardcodes the
// CLOSING tag as </a:outerShdw> regardless of type, so an inset box-shadow
// lowered to shadow.type:'inner' emits a malformed <a:innerShdw>…</a:outerShdw>
// pair — well-formed XML is broken and PowerPoint offers to "repair" the file.
// Repair the mismatched close on every slide. The `includes` guard makes this a
// no-op for decks without inner shadows; the non-greedy match stops at the
// first (i.e. this shadow's own, malformed) close, so it is safe when other
// shapes carry real outer shadows. Idempotent.
for (const name of Object.keys(zip.files).filter((n) => /^ppt\/slides\/slide\d+\.xml$/.test(n))) {
const xml = await zip.file(name).async('string')
if (!xml.includes('<a:innerShdw')) continue
const fixed = xml.replace(/(<a:innerShdw\b[\s\S]*?)<\/a:outerShdw>/g, '$1</a:innerShdw>')
if (fixed !== xml) zip.file(name, fixed)
}
if (Object.keys(svgFallbacks).length) {
// pptxgenjs pushes the PNG fallback immediately before each SVG part, so
// media/image-N-M.svg pairs with media/image-N-(M-1).png.
const svgMedia = Object.keys(zip.files)
.map((n) => /^ppt\/media\/image-(\d+)-(\d+)\.svg$/.exec(n))
.filter(Boolean)
.sort((a, b) => +a[1] - +b[1] || +a[2] - +b[2])
const perSlideIdx = {}
for (const m of svgMedia) {
const slideNum = +m[1]
const k = (perSlideIdx[slideNum] = (perSlideIdx[slideNum] ?? -1) + 1)
const png = svgFallbacks[slideNum]?.[k]
const target = `ppt/media/image-${slideNum}-${+m[2] - 1}.png`
if (png && zip.file(target)) {
zip.file(target, png.replace(/^data:image\/png;base64,/, ''), { base64: true })
}
}
}
return zip.generateAsync({
type: outputType,
compression: 'DEFLATE',
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
})
}
// Retype a finished .pptx as a PowerPoint TEMPLATE (.potx): the only OOXML
// difference is the presentation part's content type. Run LAST (after
// patchPptx) — it only rewrites [Content_Types].xml.
export async function toPotx(data, outputType) {
const zip = await JSZip.loadAsync(data)
const ct = await zip.file('[Content_Types].xml').async('string')
zip.file(
'[Content_Types].xml',
ct.replace(
'application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml',
'application/vnd.openxmlformats-officedocument.presentationml.template.main+xml',
),
)
return zip.generateAsync({
type: outputType,
compression: 'DEFLATE',
mimeType: 'application/vnd.openxmlformats-officedocument.presentationml.template',
})
}
/**
* brandkit.mjs — any corporate deck yields its own brand kit (E8.1 step 4).
*
* We read `ppt/theme/themeN.xml` <a:clrScheme> and, when present, the running
* logo triage found, and emit a `.unpaper-brand.json` that passes unPaper's
* STRICT `parseBrandKit` (src/lib/unpaper.ts). The strict schema keys theme /
* bg / accent to fixed ENUMS, so the deck's real colours ride in
* `settings.*Color` with `paletteMode:"custom"` — that is how a kit "carries the
* theme palette" without inventing enum ids the validator would reject.
*/
import { LOGO_MAX_CHARS, toBase64 } from './shared.mjs'
// unPaper DEFAULT_TEMPLATE_SETTINGS (mirrored here so this stays a pure .mjs with
// no TS import; parseBrandKit heals any drift by rebuilding over its own default).
const DEFAULTS = {
header: true, footer: true, footerRule: true, pageNumbers: true,
paletteMode: 'auto', fontPreset: 'default',
canvasColor: '#ffffff', textColor: '#111827', mutedTextColor: '#4b5563',
panelColor: '#ffffff', lineColor: '#cbd5e1', accentColor: '#2563a8',
accentTextColor: '#ffffff', gradientFrom: '#ffffff', gradientTo: '#2563a8',
gradientGlow: '#2563a8', gradientAngle: 150,
}
const NAMED = { windowtext: '000000', window: 'FFFFFF', black: '000000', white: 'FFFFFF' }
// One <a:clrScheme> slot (dk1, lt1, accent1, …) → "#RRGGBB" or null.
function slotColor(schemeXml, slot) {
const block = schemeXml.match(new RegExp(`<a:${slot}>([\\s\\S]*?)<\\/a:${slot}>`))?.[1]
if (!block) return null
const srgb = block.match(/<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/)?.[1]
if (srgb) return `#${srgb.toUpperCase()}`
const sys = block.match(/<a:sysClr\b[^>]*\blastClr="([0-9A-Fa-f]{6})"/)?.[1]
if (sys) return `#${sys.toUpperCase()}`
const byName = block.match(/<a:sysClr\s+val="(\w+)"/)?.[1]
if (byName && NAMED[byName.toLowerCase()]) return `#${NAMED[byName.toLowerCase()]}`
return null
}
function luminance(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '')
if (!m) return 1
const [r, g, b] = [1, 2, 3].map((i) => parseInt(m[i], 16) / 255)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
export function buildBrandKit({ themeXml, name, logo }) {
const scheme = themeXml.match(/<a:clrScheme\b[\s\S]*?<\/a:clrScheme>/)?.[0] || ''
const accent1 = slotColor(scheme, 'accent1')
const dk1 = slotColor(scheme, 'dk1')
const dk2 = slotColor(scheme, 'dk2')
const lt1 = slotColor(scheme, 'lt1')
const lt2 = slotColor(scheme, 'lt2')
const canvasColor = lt1 || DEFAULTS.canvasColor
const textColor = dk1 || DEFAULTS.textColor
const accentColor = accent1 || DEFAULTS.accentColor
const settings = {
...DEFAULTS,
paletteMode: 'custom',
canvasColor,
textColor,
mutedTextColor: dk2 || DEFAULTS.mutedTextColor,
panelColor: lt1 || DEFAULTS.panelColor,
lineColor: lt2 || DEFAULTS.lineColor,
accentColor,
gradientFrom: lt1 || DEFAULTS.gradientFrom,
gradientTo: accentColor,
gradientGlow: accentColor,
}
// theme/bg/accent stay valid enums: pick the theme by canvas darkness, keep the
// accent "default" (the real accent lives in settings.accentColor).
const theme = luminance(canvasColor) < 0.4 ? 'midnight' : 'paper'
const kit = {
version: 1,
name: name || 'Imported deck',
theme,
bg: 'plain',
accent: 'default',
settings,
meta: { eyebrow: 'Briefing', brand: name || 'Imported deck', foot: 'Confidential', ref: '' },
}
if (logo && logo.buf) {
const dataUri = `data:${logo.mime};base64,${toBase64(logo.buf)}`
// Only embed if under the E5.1 cap AND a browser-renderable raster; else palette-only.
if (dataUri.length <= LOGO_MAX_CHARS && /^data:image\/(png|jpeg|gif|webp|bmp)/i.test(dataUri)) {
kit.logo = { dataUri, alt: name || 'logo' }
}
}
return kit
}
/**
* Chart parts are DATA, not pictures (E8.1, un-flattening level 1).
*
* When a slide's graphicFrame references `ppt/charts/chartN.xml`, we read the
* series CACHES that every writer embeds (`<c:cat>` / `<c:val>` point values)
* and emit the live `data-unpaper-chart` contract the forward engine already
* round-trips (see src/lib/html2pptx/extract.mjs: `{ type, categories,
* series:[{ name, values }] }`). The imported deck comes back with an EDITABLE
* PowerPoint chart, not a flat picture.
*
* The engine accepts these types: bar, column, line, area, pie, doughnut, donut.
* Anything exotic (scatter, radar, bubble, stock, surface) is rescued as "bar"
* with a flag so the values still survive — content over chrome.
*/
import { decodeXml } from './shared.mjs'
// OOXML chart element → unpaper chart type. barChart splits on barDir.
function chartType(xml) {
if (/<c:pieChart\b/.test(xml)) return { type: 'pie', flags: [] }
if (/<c:doughnutChart\b/.test(xml)) return { type: 'doughnut', flags: [] }
if (/<c:lineChart\b/.test(xml)) return { type: 'line', flags: [] }
if (/<c:areaChart\b/.test(xml)) return { type: 'area', flags: [] }
if (/<c:barChart\b/.test(xml)) {
const dir = xml.match(/<c:barDir\s+val="(\w+)"/)?.[1]
return { type: dir === 'bar' ? 'bar' : 'column', flags: [] }
}
// scatter/radar/bubble/stock/surface — keep the numbers, flag the shape.
const other = xml.match(/<c:(scatter|radar|bubble|stock|surface)Chart\b/)?.[1]
if (other) return { type: 'bar', flags: [`chart-type-approx:${other}`] }
return null
}
// Pull "<c:pt idx=N><c:v>…</c:v></c:pt>" points out of a cache block into a dense
// array (idx-ordered, gaps filled). `numeric` coerces to finite numbers (else 0).
function points(block, numeric) {
if (!block) return []
const byIdx = []
let max = -1
for (const m of block.matchAll(/<c:pt\b[^>]*\bidx="(\d+)"[^>]*>([\s\S]*?)<\/c:pt>/g)) {
const idx = +m[1]
const v = m[2].match(/<c:v>([\s\S]*?)<\/c:v>/)
if (!v) continue
const raw = decodeXml(v[1])
byIdx[idx] = numeric ? (Number.isFinite(+raw) ? +raw : 0) : raw
if (idx > max) max = idx
}
if (max < 0) return []
const out = []
for (let i = 0; i <= max; i++) out.push(numeric ? (byIdx[i] ?? 0) : (byIdx[i] ?? ''))
return out
}
const firstBlock = (xml, tag) => xml.match(new RegExp(`<c:${tag}>([\\s\\S]*?)<\\/c:${tag}>`))?.[1] || null
/**
* Parse a chartN.xml string → { type, categories, series:[{name,values}], flags }
* or null when there is nothing chart-shaped to rescue. Defensive: any single
* malformed series is skipped, never thrown.
*/
export function parseChartXml(xml) {
const t = chartType(xml)
if (!t) return null
const flags = [...t.flags]
const serBlocks = [...xml.matchAll(/<c:ser>([\s\S]*?)<\/c:ser>/g)].map((m) => m[1])
if (!serBlocks.length) return null
let categories = null
const series = []
for (let i = 0; i < serBlocks.length; i++) {
const ser = serBlocks[i]
// Series name: cache value inside <c:tx>.
const txBlock = firstBlock(ser, 'tx')
const name = txBlock
? decodeXml(txBlock.match(/<c:v>([\s\S]*?)<\/c:v>/)?.[1] ?? '') || `Series ${i + 1}`
: `Series ${i + 1}`
// Categories: first series with a <c:cat> defines them (they repeat per series).
const catBlock = firstBlock(ser, 'cat')
if (!categories && catBlock) {
const cats = points(catBlock, false).map((c) => String(c))
if (cats.length) categories = cats
}
// Values: <c:val> numeric cache.
const values = points(firstBlock(ser, 'val'), true)
if (values.length) series.push({ name, values })
}
if (!series.length) return null
// No category labels (e.g. a bare scatter) → synthesize 1..n so the contract validates.
if (!categories || !categories.length) {
const n = Math.max(...series.map((s) => s.values.length))
categories = Array.from({ length: n }, (_, i) => String(i + 1))
flags.push('chart-categories-synthesized')
}
// The engine requires every series length === categories length; pad/trim defensively.
for (const s of series) {
if (s.values.length < categories.length) {
while (s.values.length < categories.length) s.values.push(0)
} else if (s.values.length > categories.length) {
s.values.length = categories.length
}
}
return { type: t.type, categories, series, flags }
}
/**
* deposition.mjs — E8.8: evidence, not translation (BACKWARD-COMPILER §3).
*
* The deposition is what the USER'S multimodal AI reads to recover design
* intent: cascade-resolved facts (resolve.mjs) + contact-sheet renders of the
* Grade A verbatim (small pictures the AI can actually look at) + a rebuild
* prompt that teaches it to use both. Zero AI inside unPaper — the model in
* the loop is the user's.
*
* emitDeposition(zip, { base, chrome, log }) →
* { factsPath, contactSheets[], promptPath, lowConfidenceSlides[] }
*
* Contact sheets are composed IN THE PAGE (an HTML grid of the per-slide
* screenshots, itself screenshot) — no image library needed. ≤12 slides per
* sheet at ~480×270; a sheet stays well under 2 MB.
*/
import { writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { createRequire } from 'node:module'
import { loadDeck, resolveSlide, primeIslandCaches } from './resolve.mjs'
import { emitVerbatim } from './verbatim.mjs'
const require = createRequire(import.meta.url)
const PER_SHEET = 12
const THUMB_W = 480
const THUMB_H = 270
export async function emitDeposition(zip, { base, chrome, log = () => {} }) {
const model = await primeIslandCaches(await loadDeck(zip))
// ── facts.json: per-slide resolver output + a confidence summary ──────────
const slides = []
const lowConfidenceSlides = []
for (let i = 0; i < model.slides.length; i++) {
const facts = resolveSlide(model, i)
const total = facts.elements.length || 1
const resolved = facts.elements.filter((e) => e.confidence === 'resolved').length
const confidence = resolved / total
if (confidence < 0.8) lowConfidenceSlides.push(i + 1)
slides.push({ ...facts, confidence: Math.round(confidence * 100) / 100 })
}
const factsPath = base + '.facts.json'
writeFileSync(
factsPath,
JSON.stringify({ generator: 'pptx2unpaper-deposition', size: [1280, 720], slides, lowConfidenceSlides }, null, 2) + '\n',
)
log(`facts → ${factsPath} (${slides.length} slides, ${lowConfidenceSlides.length} low-confidence)`)
// ── contact sheets: Grade A slides, shot small, gridded in-page ───────────
const puppeteer = require('puppeteer-core')
const html = await emitVerbatim(zip, { title: path.basename(base), source: path.basename(base) })
const tmp = mkdtempSync(path.join(tmpdir(), 'p2h-depo-'))
const vPath = path.join(tmp, 'verbatim.html')
writeFileSync(vPath, html)
const browser = await puppeteer.launch({ executablePath: chrome, headless: true })
const contactSheets = []
try {
const page = await browser.newPage()
await page.setViewport({ width: 1312, height: 760, deviceScaleFactor: 1 })
await page.goto(pathToFileURL(vPath).href, { waitUntil: 'networkidle0' })
const els = await page.$$('.slide')
const thumbs = []
for (let i = 0; i < els.length; i++) {
thumbs.push('data:image/jpeg;base64,' + Buffer.from(await els[i].screenshot({ type: 'jpeg', quality: 68 })).toString('base64'))
}
for (let s = 0; s * PER_SHEET < thumbs.length; s++) {
const batch = thumbs.slice(s * PER_SHEET, (s + 1) * PER_SHEET)
const cols = Math.min(3, batch.length)
const rows = Math.ceil(batch.length / cols)
const sheetHtml =
`<!doctype html><html><head><style>*{margin:0;box-sizing:border-box}body{background:#fff;width:${cols * (THUMB_W + 8) + 8}px;padding:4px}` +
`.g{display:grid;grid-template-columns:repeat(${cols},${THUMB_W}px);gap:8px;padding:4px}` +
`.c{position:relative}.c img{width:${THUMB_W}px;height:${THUMB_H}px;display:block;border:1px solid #bbb}` +
`.c .n{position:absolute;top:4px;left:4px;background:#111;color:#fff;font:700 13px/1 Arial;padding:3px 7px;border-radius:3px}</style></head><body><div class="g">` +
batch.map((uri, j) => `<div class="c"><img src="${uri}"><div class="n">${s * PER_SHEET + j + 1}</div></div>`).join('') +
`</div></body></html>`
const sheetPage = await browser.newPage()
await sheetPage.setViewport({ width: cols * (THUMB_W + 8) + 16, height: rows * (THUMB_H + 8) + 16, deviceScaleFactor: 1 })
await sheetPage.setContent(sheetHtml, { waitUntil: 'networkidle0' })
const sheetPath = `${base}.contact-${s + 1}.png`
await sheetPage.screenshot({ path: sheetPath, fullPage: true })
await sheetPage.close()
contactSheets.push(sheetPath)
log(`sheet → ${sheetPath} (slides ${s * PER_SHEET + 1}–${s * PER_SHEET + batch.length})`)
}
} finally {
await browser.close()
}
// ── rebuild prompt v3: teach the AI to read pictures + facts ──────────────
const promptPath = base + '.rebuild-prompt-v3.txt'
writeFileSync(promptPath, rebuildPromptV3({ slides: slides.length, lowConfidenceSlides, base: path.basename(base) }))
log(`prompt → ${promptPath}`)
return { factsPath, contactSheets, promptPath, lowConfidenceSlides }
}
function rebuildPromptV3({ slides, lowConfidenceSlides, base }) {
const low = lowConfidenceSlides.length
? `Slides ${lowConfidenceSlides.join(', ')} parsed with LOW confidence — for those, TRUST THE PICTURE on the contact sheet over the facts file.\n\n`
: ''
return (
`REBUILD THIS DECK (deposition v3 — pictures + facts)\n\n` +
`You are looking at the deposition of an imported PowerPoint ("${base}", ${slides} slides):\n` +
`1. ${base}.contact-*.png — CONTACT SHEETS: numbered pictures of every original slide. This is the design truth: composition, hierarchy, what carried each slide.\n` +
`2. ${base}.facts.json — PER-ELEMENT FACTS: for every slide, each element's bbox [x,y,w,h] on a 1280×720 canvas, z-order, resolved style (font, size, color, fill), text content, chart/diagram data, and a geometric readingOrder. Numbers here are exact where the pictures are small.\n` +
`3. ${base}.ai.html — the CONTENT skeleton (text, tables, chart data, notes; images as <figure data-unpaper-image> placeholders).\n\n` +
low +
`Your job: rewrite this as a first-class unPaper deck — not a pixel copy. Read the contact sheet to understand what each slide MEANT (its one idea, its key visual), read facts.json when you need exact text, numbers, or an element's emphasis (size/color = what the author shouted), and rebuild each slide in the unPaper template's own idiom. Improve layout freely; never invent content. Keep every <figure data-unpaper-image> placeholder exactly where its image belongs. Charts: use the data from facts.json/ai.html on data-unpaper-chart, never redraw numbers by eye.\n\n` +
`Then return ONE complete self-contained HTML file per the unPaper rules (they follow with the template). After you deliver: the file is rehydrated (real images spliced back by id), checked, and converted to .pptx locally.\n`
)
}
/**
* docx2md.mjs — E8.3: flowing Word documents → clean, AI-ingestible Markdown.
*
* The "in through Markdown" leg of the Second Dimension (ED-6): the OUT leg
* stays unPaper HTML — there is deliberately NO DOCX→HTML converter here; the
* user's AI composes that leg. Pure Node + jszip over word/document.xml.
*
* Honest scope (vs MarkItDown/Docling/pandoc — we don't win on breadth, we
* win on the round trip): headings (pStyle Heading1–9 / outlineLvl),
* bold/italic, nested numbered+bullet lists (through the num→abstractNum
* indirection), tables (pipes; any merged cell falls the whole table back to
* an HTML block), hyperlinks, footnotes, images as placeholders by default
* (MD is AI input; images are usually noise) or extracted with
* `images:"dir"`, text boxes as origin-noted blockquotes. Skipped on
* purpose: field codes/TOC, SmartArt/shapes beyond their text, comments,
* tracked changes (accepted text wins), styling beyond b/i.
*/
import { decodeXml, readRels, extOf } from './shared.mjs'
const innerOf = (xml, tag) => {
const m = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`).exec(xml)
return m ? m[1] : null
}
const attrOf = (tag, name) => new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
const selfVal = (xml, tag) => new RegExp(`<${tag}\\b[^>]*\\bw:val="([^"]*)"`).exec(xml)?.[1]
// balanced top-level blocks of the body: w:p and w:tbl in document order
function bodyBlocks(bodyXml) {
const blocks = []
const re = /<w:(p|tbl)[ >]/g
let m
while ((m = re.exec(bodyXml))) {
const tag = `w:${m[1]}`
const close = `</${tag}>`
let depth = 0
const scan = new RegExp(`<${tag}[ >/]|</${tag}>`, 'g')
scan.lastIndex = m.index
let end = bodyXml.length
let sm
while ((sm = scan.exec(bodyXml))) {
if (sm[0].startsWith(`</`)) {
depth--
if (depth === 0) {
end = sm.index + close.length
break
}
} else if (!sm[0].endsWith('/>')) depth++
}
blocks.push({ tag, xml: bodyXml.slice(m.index, end) })
re.lastIndex = end
}
return blocks
}
// numbering.xml: numId+ilvl → { fmt } through the num→abstractNum indirection
function numberingLookup(numberingXml) {
const abstract = new Map()
for (const m of numberingXml.matchAll(/<w:abstractNum\b[^>]*w:abstractNumId="(\d+)"[\s\S]*?<\/w:abstractNum>/g)) {
const lvls = new Map()
for (const lm of m[0].matchAll(/<w:lvl\b[^>]*w:ilvl="(\d+)"[\s\S]*?<\/w:lvl>/g)) {
lvls.set(lm[1], selfVal(lm[0], 'w:numFmt') ?? 'bullet')
}
abstract.set(m[1], lvls)
}
const nums = new Map()
for (const m of numberingXml.matchAll(/<w:num\b[^>]*w:numId="(\d+)"[^>]*>([\s\S]*?)<\/w:num>/g)) {
const absId = selfVal(m[2], 'w:abstractNumId')
if (absId !== undefined) nums.set(m[1], abstract.get(absId) ?? new Map())
}
return (numId, ilvl) => nums.get(numId)?.get(String(ilvl)) ?? null
}
function runsToMd(xml, ctx) {
let out = ''
// walk run-level nodes in order: hyperlinks wrap runs; footnote refs and
// drawings live inside runs — handle w:hyperlink first, then plain w:r
const re = /<w:hyperlink\b[^>]*>[\s\S]*?<\/w:hyperlink>|<w:r\b[^>]*>[\s\S]*?<\/w:r>|<w:r\/>/g
let m
while ((m = re.exec(xml))) {
const chunk = m[0]
if (chunk.startsWith('<w:hyperlink')) {
const rid = attrOf(chunk, 'r:id')
const url = rid ? ctx.rels[rid]?.target ?? '' : ''
const inner = runsToMd(innerOf(chunk, 'w:hyperlink') ?? '', ctx)
out += url && /^https?:/i.test(url) ? `[${inner}](${url})` : inner
continue
}
// one run
const rPr = innerOf(chunk, 'w:rPr') ?? ''
const bold = /<w:b\b(?![^>]*w:val="(?:0|false)")/.test(rPr)
const italic = /<w:i\b(?![^>]*w:val="(?:0|false)")/.test(rPr)
let text = ''
for (const t of chunk.matchAll(/<w:t\b[^>]*>([\s\S]*?)<\/w:t>|<w:tab\/>|<w:br\/>/g)) {
if (t[0] === '<w:tab/>') text += '\t'
else if (t[0] === '<w:br/>') text += ' \n'
else text += decodeXml(t[1])
}
const fn = /<w:footnoteReference\b[^>]*w:id="(\d+)"/.exec(chunk)
if (fn) text += `[^${fn[1]}]`
if (/<w:drawing\b/.test(chunk) || /<w:pict\b/.test(chunk)) {
ctx.imageN++
const embed = /(?:r:embed|r:link)="(rId\d+)"/.exec(chunk)?.[1]
const target = embed ? ctx.rels[embed]?.target : null
text += ctx.imageMode === 'dir' && target ? `![image ${ctx.imageN}](${ctx.imageOut(target, ctx.imageN)})` : `![image ${ctx.imageN}](placeholder)`
}
if (!text) continue
const trimmed = text.trim()
if (trimmed && (bold || italic)) {
const mark = bold && italic ? '***' : bold ? '**' : '*'
const lead = text.match(/^\s*/)[0]
const tailWs = text.match(/\s*$/)[0]
text = `${lead}${mark}${trimmed}${mark}${tailWs}`
}
out += text
}
return out
}
function paraToMd(pXml, ctx) {
// text boxes carry their own paragraphs — surface as an origin-noted quote
const txbx = innerOf(pXml, 'w:txbxContent')
if (txbx) {
const inner = bodyBlocks(txbx)
.map((b) => (b.tag === 'w:p' ? paraToMd(b.xml, { ...ctx, inTxbx: true }) : ''))
.filter(Boolean)
.join(' ')
return inner ? `> ${inner}\n> — from a text box in the source document\n` : ''
}
const pPr = innerOf(pXml, 'w:pPr') ?? ''
const styleVal = selfVal(pPr, 'w:pStyle') ?? ''
const outline = selfVal(pPr, 'w:outlineLvl')
const headingLevel = /^Heading([1-9])$/.exec(styleVal)?.[1] ?? (outline !== undefined ? String(Number(outline) + 1) : null)
const body = runsToMd(pXml.replace(/<w:pPr>[\s\S]*?<\/w:pPr>/, ''), ctx).trim()
if (!body) return ''
if (headingLevel) return `${'#'.repeat(Math.min(6, Number(headingLevel)))} ${body}\n`
const numPr = innerOf(pPr, 'w:numPr')
if (numPr) {
const numId = selfVal(numPr, 'w:numId')
const ilvl = Number(selfVal(numPr, 'w:ilvl') ?? 0)
const fmt = ctx.numFmt(numId, ilvl)
const marker = fmt === 'decimal' || fmt === 'lowerLetter' || fmt === 'upperLetter' || fmt === 'lowerRoman' || fmt === 'upperRoman' ? '1.' : '-'
return `${' '.repeat(ilvl)}${marker} ${body}\n`
}
return `${body}\n`
}
function tableToMd(tblXml, ctx) {
const rows = []
let merged = false
for (const tr of tblXml.matchAll(/<w:tr\b[\s\S]*?<\/w:tr>/g)) {
const cells = []
for (const tc of tr[0].matchAll(/<w:tc\b[\s\S]*?<\/w:tc>/g)) {
const tcPr = innerOf(tc[0], 'w:tcPr') ?? ''
if (/<w:gridSpan\b/.test(tcPr) || /<w:vMerge\b/.test(tcPr)) merged = true
const text = bodyBlocks(innerOf(tc[0], 'w:tc') ?? tc[0])
.map((b) => (b.tag === 'w:p' ? paraToMd(b.xml, ctx).trim() : ''))
.filter(Boolean)
.join(' ')
cells.push(text.replace(/\|/g, '\\|'))
}
rows.push(cells)
}
if (!rows.length) return ''
if (merged) {
// MD pipes can't express spans — fall back to an HTML table block (still
// fine as AI input, and honest about the structure)
const html = rows.map((r) => ` <tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('\n')
return `<table>\n${html}\n</table>\n\n(merged cells preserved as HTML — pipe tables cannot express spans)\n`
}
const width = Math.max(...rows.map((r) => r.length))
const pad = (r) => [...r, ...Array(width - r.length).fill('')]
const line = (r) => `| ${pad(r).join(' | ')} |`
return [line(rows[0]), `| ${Array(width).fill('---').join(' | ')} |`, ...rows.slice(1).map(line)].join('\n') + '\n'
}
/** zip (a .docx) → { markdown, images: [{n, target}] } */
export async function docxToMarkdown(zip, { images = 'placeholder', writeImage } = {}) {
const doc = await zip.file('word/document.xml')?.async('string')
if (!doc) throw new Error('No word/document.xml — is this a .docx?')
const rels = await readRels(zip, 'word/document.xml')
const numberingXml = (await zip.file('word/numbering.xml')?.async('string')) ?? ''
const footnotesXml = (await zip.file('word/footnotes.xml')?.async('string')) ?? ''
const extracted = []
const ctx = {
rels,
numFmt: numberingLookup(numberingXml),
imageN: 0,
imageMode: images,
imageOut: (target, n) => {
const name = `image-${String(n).padStart(2, '0')}.${extOf(target) || 'png'}`
extracted.push({ n, target, name })
return name
},
}
const body = innerOf(doc, 'w:body') ?? doc
const parts = []
for (const block of bodyBlocks(body)) {
// skip TOC/field machinery wholesale: paragraphs that are only field codes
if (block.tag === 'w:p' && /<w:instrText\b/.test(block.xml) && !/<w:t\b/.test(block.xml.replace(/<w:instrText[\s\S]*?<\/w:instrText>/g, ''))) continue
parts.push(block.tag === 'w:p' ? paraToMd(block.xml, ctx) : tableToMd(block.xml, ctx))
}
// footnote bodies, in id order
const notes = []
for (const fn of footnotesXml.matchAll(/<w:footnote\b[^>]*w:id="(\d+)"[\s\S]*?<\/w:footnote>/g)) {
if (/w:type="(?:separator|continuationSeparator)"/.test(fn[0])) continue
const text = bodyBlocks(innerOf(fn[0], 'w:footnote') ?? '')
.map((b) => (b.tag === 'w:p' ? paraToMd(b.xml, ctx).trim() : ''))
.filter(Boolean)
.join(' ')
if (text) notes.push(`[^${fn[1]}]: ${text}`)
}
// extract image bytes when asked
if (images === 'dir' && writeImage) {
for (const img of extracted) {
const file = zip.file(img.target)
if (file) writeImage(img.name, await file.async('nodebuffer'))
}
}
const markdown = (parts.filter(Boolean).join('\n') + (notes.length ? '\n' + notes.join('\n') + '\n' : ''))
.replace(/\n{3,}/g, '\n\n')
return { markdown, images: extracted }
}
/**
* elevate.mjs — the format elevator (E8.9, BACKWARD-COMPILER §3 Move 4).
*
* Old office files (.ppt/.pps binary OLE, .odp) and pathological modern .pptx
* are ELEVATED to clean OOXML by a LOCAL LibreOffice (`soffice --convert-to
* pptx`) before the importer parses them. LO is a format elevator here, never
* an engine (E-0c). CLI-only — the web /import surface never receives legacy
* formats.
*
* maybeElevate(inPath, { force }) → { path, elevated } (throws with an
* actionable message when elevation is needed but soffice is missing)
*/
import { execFileSync } from 'node:child_process'
import { existsSync, mkdtempSync, openSync, readSync, closeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
const SOFFICE_PATHS = [
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
'/opt/homebrew/bin/soffice',
'/usr/local/bin/soffice',
'/usr/bin/soffice',
'soffice', // PATH fallback
]
export function findSoffice() {
for (const p of SOFFICE_PATHS) {
if (p === 'soffice') {
try {
execFileSync('soffice', ['--version'], { stdio: 'pipe' })
return 'soffice'
} catch {
continue
}
}
if (existsSync(p)) return p
}
return null
}
// OLE compound-file magic: D0 CF 11 E0 A1 B1 1A E1 (the .ppt/.pps container).
function isOle(filePath) {
const fd = openSync(filePath, 'r')
try {
const buf = Buffer.alloc(4)
readSync(fd, buf, 0, 4, 0)
return buf[0] === 0xd0 && buf[1] === 0xcf && buf[2] === 0x11 && buf[3] === 0xe0
} finally {
closeSync(fd)
}
}
const LEGACY_EXT = /\.(ppt|pps|odp)$/i
/** Decide whether `inPath` needs elevation; run it when it does. `force`
* re-normalizes even a modern .pptx (the --elevate retry path for files that
* failed to parse). Returns { path, elevated }. */
export function maybeElevate(inPath, { force = false } = {}) {
const legacy = LEGACY_EXT.test(inPath) || isOle(inPath)
if (!legacy && !force) return { path: inPath, elevated: false }
const soffice = findSoffice()
if (!soffice) {
const what = legacy ? `"${path.basename(inPath)}" is a legacy office file (${LEGACY_EXT.test(inPath) ? path.extname(inPath) : 'OLE binary'})` : `--elevate needs LibreOffice`
throw new Error(
`${what}, and no LibreOffice was found to elevate it.\n` +
` Either: install LibreOffice (macOS: brew install --cask libreoffice) and retry,\n` +
` or: open the file in PowerPoint/LibreOffice yourself and re-save it as .pptx.`,
)
}
const outDir = mkdtempSync(path.join(tmpdir(), 'p2h-elevate-'))
execFileSync(soffice, [
'--headless',
`-env:UserInstallation=file://${outDir}/profile`,
'--convert-to',
'pptx',
'--outdir',
outDir,
inPath,
], { stdio: 'pipe', timeout: 120000 })
const elevated = path.join(outDir, path.basename(inPath).replace(/\.[^.]+$/, '') + '.pptx')
if (!existsSync(elevated)) throw new Error(`LibreOffice produced no .pptx for ${path.basename(inPath)} — the file may be corrupt.`)
return { path: elevated, elevated: true }
}
/**
* import.mjs — the E8.1 importer core.
*
* `import deck.pptx` rescues the CONTENT of a .pptx into unPaper HTML and emits
* THREE files:
*
* deck.unpaper.html self-contained + archival (real image bytes inline)
* deck.ai.html KILOBYTES — every kept image becomes a
* <figure data-unpaper-image="img_N"> placeholder, so the
* whole deck fits an AI's context. Token math: a 5 MB photo
* ≈ 6.7 M base64 chars ≈ ~1.7 M tokens; a placeholder ≈ 40.
* deck.images.json registry { id, sha256, mime, w, h, sourceSlide, b64 }
* written streaming — one image at a time.
*
* Plus deck.ai.rebuild-prompt.txt (v2, with the placeholder contract) and,
* optionally, deck.unpaper-brand.json (--emit-brand-kit).
*
* Design law (Second Dimension brief): the moat is the ROUND TRIP. Charts come
* back as EDITABLE data (data-unpaper-chart), repeated logos are extracted once
* into a brand kit, backdrops are dropped, content images ride the registry and
* are spliced back by `rehydrate` after the AI redesigns deck.ai.html.
*
* Pure Node + jszip. No network, no AI, no image recompression (the size cap +
* placeholders ARE the size strategy).
*/
import JSZip from 'jszip'
import {
decodeXml, paraText, readTxBody, readRels,
extOf, mimeOf, sha256, emuToPx, imageSize, imgId, isoDate, toBase64, VECTOR_EXTS,
} from './shared.mjs'
import { parseChartXml } from './charts.mjs'
import { buildUnpaperDoc, buildAiDoc, buildRebuildPrompt } from './templates.mjs'
import { buildBrandKit } from './brandkit.mjs'
// Filesystem access happens ONLY in path mode (CLI/Node callers). The
// indirection keeps the literal out of static analysis, so webpack/turbopack
// bundle this file clean for the browser /import surface — where bytes mode
// never reaches these calls.
const nodeFs = () => new Function('m', 'return import(m)')('node:fs/promises')
const PKG_VERSION = '0.1.0'
// ── Slide parsing ────────────────────────────────────────────────────────────
// Walk top-level <p:sp>/<p:graphicFrame>/<p:pic> in document order. Grouped
// shapes (p:grpSp) are not descended — same scope as the E6.1 spike; the E8.6
// cascade resolver is where geometry-faithful group handling lands.
function parseSlide(xml) {
const blocks = []
const re = /<p:(sp|graphicFrame|pic)\b[\s\S]*?<\/p:\1>/g
let m
while ((m = re.exec(xml))) {
const chunk = m[0]
const off = chunk.match(/<a:off x="(-?\d+)" y="(-?\d+)"/)
const ext = chunk.match(/<a:ext cx="(\d+)" cy="(\d+)"/)
const x = off ? +off[1] : 0
const y = off ? +off[2] : 0
const w = ext ? +ext[1] : 0
const h = ext ? +ext[2] : 0
if (m[1] === 'graphicFrame' && /<a:tbl>/.test(chunk)) {
const rows = [...chunk.matchAll(/<a:tr\b[\s\S]*?<\/a:tr>/g)].map((r) =>
[...r[0].matchAll(/<a:tc>([\s\S]*?)<\/a:tc>/g)].map((c) => paraText(c[1])),
)
if (rows.length) blocks.push({ kind: 'table', y, rows })
} else if (m[1] === 'graphicFrame' && /graphicData[^>]*uri="[^"]*chart"/.test(chunk)) {
const rid = chunk.match(/<c:chart\b[^>]*r:id="(rId\d+)"/)?.[1]
if (rid) blocks.push({ kind: 'chart', y, x, w, h, rid })
} else if (m[1] === 'pic') {
const embed = chunk.match(/(?:r:embed|r:link)="(rId\d+)"/)
const descr = chunk.match(/<p:cNvPr\b[^>]*\bdescr="([^"]*)"/)?.[1]
if (embed) blocks.push({ kind: 'image', y, x, w, h, embed: embed[1], alt: descr ? decodeXml(descr) : '' })
} else {
const txBody = chunk.match(/<p:txBody>([\s\S]*?)<\/p:txBody>/)
if (!txBody) continue
const { lines, maxSz } = readTxBody(txBody[1])
// The placeholder type is the layout's own word for what the shape IS —
// run sizes are often inherited from the master and invisible here.
const ph = chunk.match(/<p:ph\b[^>]*type="([^"]+)"/)?.[1]
if (lines.length) blocks.push({ kind: 'text', y, x, lines, maxSz, ph })
}
}
return blocks
}
// docProps/core.xml <dc:title>, else the file name.
async function deckTitle(zip, fallback) {
const core = zip.file('docProps/core.xml')
if (core) {
const xml = await core.async('string')
const t = xml.match(/<dc:title>([\s\S]*?)<\/dc:title>/)?.[1]
if (t && decodeXml(t).trim()) return decodeXml(t).trim()
}
return fallback
}
export async function runImport(args) {
// MCP-DESIGN rule 4: `in` may be a file path OR the .pptx bytes themselves
// (Buffer/Uint8Array) — sandboxed agents pass bytes, not paths. With bytes,
// `args.name` (default "deck.pptx") names outputs + provenance, and unless
// an explicit `args.out` is given NOTHING is written: the artifacts come
// back as strings on `artifacts` (registry JSON included — callers worried
// about memory on huge decks should pass `args.out` and stream from disk).
const inIsBytes = typeof args.in !== 'string'
const zip = await JSZip.loadAsync(inIsBytes ? args.in : await (await nodeFs()).readFile(args.in))
const presXml = await zip.file('ppt/presentation.xml').async('string')
const presRels = await readRels(zip, 'ppt/presentation.xml')
const slidePaths = [...presXml.matchAll(/<p:sldId\b[^>]*r:id="(rId\d+)"/g)]
.map((mm) => presRels[mm[1]]?.target)
.filter((p) => p && zip.file(p))
if (!slidePaths.length) throw new Error('No slides found — is this a .pptx?')
const sldSz = presXml.match(/<p:sldSz\b[^>]*cx="(\d+)"[^>]*cy="(\d+)"/)
const slideAreaPx = sldSz ? emuToPx(+sldSz[1]) * emuToPx(+sldSz[2]) : 1280 * 720
// ── Pass 1: parse every slide (blocks, rels, notes) into memory ─────────────
const slidesData = []
for (let i = 0; i < slidePaths.length; i++) {
const sp = slidePaths[i]
const xml = await zip.file(sp).async('string')
const rels = await readRels(zip, sp)
const blocks = parseSlide(xml)
let notes = ''
const notesRel = Object.values(rels).find((r) => /notesSlide\d+\.xml$/.test(r.target))
if (notesRel && zip.file(notesRel.target)) {
const nXml = await zip.file(notesRel.target).async('string')
notes = [...nXml.matchAll(/<a:p>([\s\S]*?)<\/a:p>/g)].map((mm) => paraText(mm[1])).filter(Boolean).join(' ')
notes = notes.replace(/\s*\d+\s*$/, '').trim()
}
slidesData.push({ blocks, rels, notes })
}
// ── Running-text chrome (repeated header/footer strings) ────────────────────
const freq = new Map()
for (const { blocks } of slidesData) {
const seen = new Set()
for (const b of blocks) {
if (b.kind !== 'text' || b.lines.length !== 1) continue
const key = b.lines[0].trim().toLowerCase()
if (key.length > 42 || seen.has(key)) continue
seen.add(key)
freq.set(key, (freq.get(key) || 0) + 1)
}
}
const runThreshold = Math.max(3, Math.ceil(slidePaths.length * 0.4))
const runningText = new Set([...freq].filter(([, n]) => n >= runThreshold).map(([k]) => k))
// ── Image triage (classify every referenced media asset) ────────────────────
// First, resolve each image block to its media bytes + sha + intrinsic size,
// and count how many distinct slides each sha appears on (the logo signal).
const mediaCache = new Map() // target -> { buf, sha, mime, ext, w, h }
const shaSlides = new Map() // sha -> Set(slideIndex)
for (let i = 0; i < slidesData.length; i++) {
const { blocks, rels } = slidesData[i]
for (const b of blocks) {
if (b.kind !== 'image') continue
const rel = rels[b.embed]
const file = rel && zip.file(rel.target)
if (!file) { b.missing = true; continue }
if (!mediaCache.has(rel.target)) {
const buf = await file.async('uint8array')
const ext = extOf(rel.target)
const dim = imageSize(buf)
mediaCache.set(rel.target, {
buf, sha: sha256(buf), mime: mimeOf(rel.target), ext,
w: dim?.w || emuToPx(b.w) || 0, h: dim?.h || emuToPx(b.h) || 0,
})
}
const info = mediaCache.get(rel.target)
b.target = rel.target
b.sha = info.sha
if (!shaSlides.has(info.sha)) shaSlides.set(info.sha, new Set())
shaSlides.get(info.sha).add(i)
}
}
// Classify per occurrence.
// backdrop → bbox covers ≈ the whole slide → drop (flattened background)
// logo → small AND same sha on ≥40% of slides → running chrome, extract once
// vector → EMF/WMF → registry placeholder (browsers can't render these)
// content → keep + registry
const logoShas = new Set()
for (const [sha, slides] of shaSlides) {
if (slides.size < runThreshold) continue
// find the smallest occurrence's box ratio as the "is it small" signal
logoShas.add(sha) // repeated across the deck → treat as chrome logo candidate
}
let brandLogo = null // { buf, mime } — first logo we keep, for the brand kit
function classify(b, info) {
if (b.missing || !info) return { drop: true, reason: 'missing' }
if (VECTOR_EXTS.has(info.ext)) return { kind: 'vector' }
const areaRatio = (emuToPx(b.w) * emuToPx(b.h)) / slideAreaPx
if (areaRatio >= 0.85) return { drop: true, reason: 'backdrop' }
const small = areaRatio > 0 && areaRatio <= 0.08
if (logoShas.has(b.sha) && small) {
if (!brandLogo) brandLogo = { buf: info.buf, mime: info.mime }
return { drop: true, reason: 'logo' }
}
return { kind: 'content' }
}
// ── Pass 2: build an abstract element list per slide, assign image ids ───────
const registry = [] // { id, sha256, mime, w, h, sourceSlide, b64, alt }
const seenContentSha = new Map() // sha -> id (dedupe identical content images)
const stats = { images: 0, imagesDropped: 0, vectors: 0, charts: 0, tables: 0, notes: 0, chartsFlagged: 0 }
const slidesEls = []
for (let i = 0; i < slidesData.length; i++) {
const { blocks, rels, notes } = slidesData[i]
if (notes) stats.notes++
const textBlocks = blocks.filter((b) => b.kind === 'text')
let titleBlock = null
for (const b of textBlocks) {
if (!titleBlock || b.maxSz > titleBlock.maxSz || (b.maxSz === titleBlock.maxSz && b.y < titleBlock.y)) titleBlock = b
}
// A title/ctrTitle PLACEHOLDER outranks the largest-run heuristic: on
// covers the date or subtitle often renders larger than the real title
// (E8.2 wild-deck finding — the Deloitte cover titled itself "May 2015"),
// and run sizes inherited from the master are invisible to readTxBody.
const phTitles = textBlocks.filter((b) => b.ph === 'title' || b.ph === 'ctrTitle')
if (phTitles.length) titleBlock = phTitles.reduce((a, b) => (b.maxSz > a.maxSz ? b : a))
const els = []
const ordered = [...blocks].sort((a, b) => a.y - b.y)
for (const b of ordered) {
if (b.kind === 'text') {
if (b !== titleBlock && b.lines.length === 1 && runningText.has(b.lines[0].trim().toLowerCase())) continue
if (b !== titleBlock && b.maxSz < 12 && /^[\d/\s.·–-]{1,9}$/.test(b.lines.join(''))) continue
if (b === titleBlock) els.push({ t: 'title', text: b.lines.join('\n') })
else if (b.lines.length > 1) els.push({ t: 'ul', items: b.lines })
else els.push({ t: 'p', text: b.lines.join('\n') })
} else if (b.kind === 'table') {
stats.tables++
els.push({ t: 'table', rows: b.rows })
} else if (b.kind === 'chart') {
const rel = rels[b.rid]
const file = rel && zip.file(rel.target)
let spec = null
if (file) spec = parseChartXml(await file.async('string'))
if (spec) {
stats.charts++
if (spec.flags.length) stats.chartsFlagged++
els.push({ t: 'chart', spec, w: emuToPx(b.w) || 640, h: emuToPx(b.h) || 380 })
} else {
// A chart frame we couldn't decode → ask the AI to rebuild it as data.
els.push({ t: 'chart-dead' })
}
} else if (b.kind === 'image') {
const info = b.target ? mediaCache.get(b.target) : null
const c = classify(b, info)
if (c.drop) { stats.imagesDropped++; continue }
if (c.kind === 'vector') {
stats.vectors++
els.push({ t: 'vector', alt: b.alt || 'vector graphic' })
continue
}
// content image → registry (dedupe identical bytes to one id)
let id = seenContentSha.get(b.sha)
if (!id) {
id = imgId(registry.length + 1)
seenContentSha.set(b.sha, id)
registry.push({
id, sha256: info.sha, mime: info.mime, w: info.w, h: info.h,
sourceSlide: i + 1, b64: toBase64(info.buf), alt: b.alt || '',
})
}
stats.images++
els.push({ t: 'image', id, mime: info.mime, w: info.w, h: info.h, alt: b.alt || '' })
}
}
if (!els.some((e) => e.t !== 'title')) {
els.push({ t: 'note', text: '(This slide had no extractable body — likely an image or diagram. Rebuild it from your notes.)' })
}
slidesEls.push({ els, notes })
}
// ── Emit ────────────────────────────────────────────────────────────────────
const sourceName = inIsBytes ? args.name || 'deck.pptx' : args.in.split(/[\\/]/).pop()
const baseName = sourceName.replace(/\.pptx$/i, '')
const title = await deckTitle(zip, baseName)
// Bytes in + no explicit out = string mode: build artifacts, write nothing.
const outUnpaper = args.out || (inIsBytes ? '' : args.in.replace(/\.pptx$/i, '') + '.unpaper.html')
const write = Boolean(outUnpaper)
const stem = (write ? outUnpaper : baseName + '.unpaper.html').replace(/\.unpaper\.html$/i, '').replace(/\.html$/i, '')
const outAi = stem + '.ai.html'
const outImages = stem + '.images.json'
const outPrompt = outAi.replace(/\.html$/i, '') + '.rebuild-prompt.txt'
const provenance = { generator: `pptx2unpaper@${PKG_VERSION}`, source: sourceName, imported: isoDate() }
const imageIndex = new Map(registry.map((r) => [r.id, r]))
const docUnpaper = buildUnpaperDoc({ title, slides: slidesEls, provenance, imageIndex })
const docAi = buildAiDoc({ title, slides: slidesEls, provenance })
const docPrompt = buildRebuildPrompt(title, slidePaths.length, { charts: stats.charts, images: stats.images })
if (write) {
const { writeFile } = await nodeFs()
await writeFile(outUnpaper, docUnpaper)
await writeFile(outAi, docAi)
await writeImageRegistry(outImages, provenance, registry)
await writeFile(outPrompt, docPrompt)
}
const outputs = write ? { unpaper: outUnpaper, ai: outAi, images: outImages, prompt: outPrompt } : {}
const artifacts = { unpaper: docUnpaper, ai: docAi, prompt: docPrompt }
// The registry JSON is built in memory only in string mode (the file path
// streams row by row — see writeImageRegistry's memory note).
if (!write) artifacts.images = imageRegistryJson(provenance, registry)
if (args.emitBrandKit) {
const themeXml = await readFirstTheme(zip)
const kit = buildBrandKit({ themeXml, name: title, logo: brandLogo })
artifacts.brand = JSON.stringify(kit, null, 2)
if (write) {
const outBrand = stem + '.unpaper-brand.json'
await (await nodeFs()).writeFile(outBrand, artifacts.brand)
outputs.brand = outBrand
}
}
return { outputs, artifacts, stats: { ...stats, slides: slidePaths.length, registry: registry.length } }
}
// String-mode registry (MCP + browser callers): BYTE-identical to
// writeImageRegistry below — same header lines, one compact row per image —
// so a web import and a CLI import of the same deck produce the same file
// (caught by the /import drive when the first multi-image fixture diverged).
function imageRegistryJson(provenance, registry) {
let out =
`{\n "generator": ${JSON.stringify(provenance.generator)},\n` +
` "source": ${JSON.stringify(provenance.source)},\n` +
` "imported": ${JSON.stringify(provenance.imported)},\n` +
' "images": ['
for (let i = 0; i < registry.length; i++) {
const r = registry[i]
out += (i ? ',' : '') + '\n ' + JSON.stringify({ id: r.id, sha256: r.sha256, mime: r.mime, w: r.w, h: r.h, sourceSlide: r.sourceSlide, alt: r.alt, b64: r.b64 })
}
return out + (registry.length ? '\n ]\n}\n' : ']\n}\n')
}
// Registry written one image at a time — never hold every base64 blob in a single
// giant JS string (a big deck's images can be hundreds of MB decoded).
async function writeImageRegistry(file, provenance, registry) {
const fh = await (await nodeFs()).open(file, 'w')
try {
await fh.write(`{\n "generator": ${JSON.stringify(provenance.generator)},\n`)
await fh.write(` "source": ${JSON.stringify(provenance.source)},\n`)
await fh.write(` "imported": ${JSON.stringify(provenance.imported)},\n`)
await fh.write(' "images": [')
for (let i = 0; i < registry.length; i++) {
const r = registry[i]
const entry = { id: r.id, sha256: r.sha256, mime: r.mime, w: r.w, h: r.h, sourceSlide: r.sourceSlide, alt: r.alt, b64: r.b64 }
await fh.write((i ? ',' : '') + '\n ' + JSON.stringify(entry))
}
await fh.write(registry.length ? '\n ]\n}\n' : ']\n}\n')
} finally {
await fh.close()
}
}
async function readFirstTheme(zip) {
const names = Object.keys(zip.files).filter((n) => /^ppt\/theme\/theme\d+\.xml$/.test(n)).sort()
return names.length ? zip.file(names[0]).async('string') : ''
}
/**
* rehydrate.mjs — splice real image bytes back into an AI-redesigned deck.
*
* After the user pastes deck.ai.html (image placeholders) into their AI and gets
* a redesigned deck back, `rehydrate` replaces every
* `<figure data-unpaper-image="img_N">` with the real `<img>` (data-URI) from
* deck.images.json — the splice is deterministic code, not model output, so the
* pixels can't be hallucinated. Unmatched ids are reported BOTH directions and
* are a non-zero exit: a dropped placeholder or a phantom id is a real defect.
*
* Placeholders are located on a COMMENT-MASKED copy (same length, so offsets map
* 1:1 onto the original) — instructional comments can contain example
* `<figure data-unpaper-image=…>` markup, and matching that would corrupt the
* splice (the same D-17 landmine spliceSlides guards against in unpaper.ts).
*/
// Path-mode fs only, loaded through an indirection no bundler follows — the
// browser /import surface calls this with strings and never touches disk.
const nodeFs = () => new Function('m', 'return import(m)')('node:fs/promises')
import { escAttr } from './shared.mjs'
const maskComments = (html) => html.replace(/<!--[\s\S]*?-->/g, (m) => ' '.repeat(m.length))
// Placeholder spans (paired first, then self-closing) located on the masked copy.
function findPlaceholders(masked) {
const spans = []
const paired = /<figure\b[^>]*\bdata-unpaper-image="([^"]+)"[^>]*>[\s\S]*?<\/figure>/g
for (const m of masked.matchAll(paired)) {
spans.push({ start: m.index, end: m.index + m[0].length, id: m[1] })
}
const selfClosing = /<figure\b[^>]*\bdata-unpaper-image="([^"]+)"[^>]*\/>/g
for (const m of masked.matchAll(selfClosing)) {
if (spans.some((s) => m.index >= s.start && m.index < s.end)) continue
spans.push({ start: m.index, end: m.index + m[0].length, id: m[1] })
}
return spans.sort((a, b) => a.start - b.start)
}
export async function runRehydrate({ html: htmlIn, registry: registryIn, out }) {
// MCP-DESIGN rule 4: `html` may be a file path or the HTML text itself
// (detected by a leading '<'); `registry` a path, a JSON string, or the
// already-parsed object. `out` is optional — when omitted nothing is
// written and the spliced document returns as `html`.
const html = /^\s*</.test(htmlIn) ? htmlIn : await (await nodeFs()).readFile(htmlIn, 'utf8')
const reg =
registryIn !== null && typeof registryIn === 'object'
? registryIn
: JSON.parse(/^\s*\{/.test(registryIn) ? registryIn : await (await nodeFs()).readFile(registryIn, 'utf8'))
const byId = new Map((reg.images || []).map((r) => [r.id, r]))
const spans = findPlaceholders(maskComments(html))
const placed = new Set()
const missing = new Set() // placeholder ids with no registry entry
const imgTag = (r, tag) => {
const w = tag.match(/\bdata-w="(\d+)"/)?.[1] || r.w
const h = tag.match(/\bdata-h="(\d+)"/)?.[1] || r.h
const dims = w && h ? ` width="${w}" height="${h}"` : ''
return (
`<img data-unpaper-image="${r.id}" alt="${escAttr(r.alt || '')}"` + dims +
` src="data:${r.mime};base64,${r.b64}" style="max-width:100%;height:auto">`
)
}
// Rebuild the original string, replacing matched spans (positions from the mask).
let outHtml = ''
let cursor = 0
for (const span of spans) {
outHtml += html.slice(cursor, span.start)
const original = html.slice(span.start, span.end)
const r = byId.get(span.id)
if (r) { placed.add(span.id); outHtml += imgTag(r, original) }
else { missing.add(span.id); outHtml += original }
cursor = span.end
}
outHtml += html.slice(cursor)
if (out) await (await nodeFs()).writeFile(out, outHtml)
const unusedRegistry = [...byId.keys()].filter((id) => !placed.has(id))
const unmatchedFigures = [...missing]
return {
out: out || null,
html: outHtml,
placed: placed.size,
unusedRegistry,
unmatchedFigures,
ok: unusedRegistry.length === 0 && unmatchedFigures.length === 0,
}
}
/**
* resolve.mjs — the cascade resolver (E8.6, RESOLVER-SPEC.md).
*
* Effective per-element geometry + style facts from a parsed .pptx:
* loadDeck(zip) → DeckModel
* resolveSlide(model, i) → SlideFacts (spec §7)
*
* Pure ESM, zero dependencies beyond jszip (loaded by the caller), no DOM —
* this module must also run inside the future browser /import surface.
* Parsing is DEFENSIVE PER ELEMENT: any unparseable shape degrades to
* kind:"rescued" (one broken shape never kills a slide, one broken slide
* never kills the deck).
*
* Where OOXML prose and this code disagree, the arbiter is a LibreOffice
* render of the fixture (spec §0). Fixtures: test/p2h-fixtures/resolver-*.
* `font.mapped` stays = original here — the forward engine's fontMap aliasing
* is applied by the Grade A emitter (E8.7), not by the resolver.
*/
import { decodeXml, readRels, EMU_PER_PX } from './shared.mjs'
import { parseChartXml } from './charts.mjs'
// ── tiny XML helpers (regex-level, like the rest of this package) ───────────
const attr = (tag, name) => new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
const numAttr = (tag, name) => {
const v = attr(tag, name)
if (v === undefined) return undefined
const n = Number(v)
if (!Number.isFinite(n)) throw new Error(`bad numeric ${name}="${v}"`)
return n
}
// first <ns:name …>…</ns:name> (or self-closing) — returns { tag, inner } or null
function firstEl(xml, name) {
const open = new RegExp(`<${name}\\b[^>]*>`).exec(xml)
if (!open) return null
if (open[0].endsWith('/>')) return { tag: open[0], inner: '' }
const close = xml.indexOf(`</${name}>`, open.index)
if (close === -1) return { tag: open[0], inner: '' }
return { tag: open[0], inner: xml.slice(open.index + open[0].length, close) }
}
const px = (emu) => Math.round((emu / EMU_PER_PX) * 10) / 10
// balanced slice of <tag …>…</tag> starting at startIdx (counts only `tag`)
function balanced(xml, startIdx, tag) {
const re = new RegExp(`<(/?)${tag}\\b[^>]*?(/?)>`, 'g')
re.lastIndex = startIdx
let depth = 0
let m
while ((m = re.exec(xml))) {
if (m[2] === '/') { if (depth === 0) return xml.slice(startIdx, m.index + m[0].length) ; continue }
depth += m[1] === '/' ? -1 : 1
if (depth === 0) return xml.slice(startIdx, m.index + m[0].length)
}
return xml.slice(startIdx)
}
// top-level drawable chunks of an spTree (skips the tree's own props)
const DRAWABLES = ['p:sp', 'p:grpSp', 'p:graphicFrame', 'p:pic', 'p:cxnSp']
function topLevelChunks(treeXml) {
const chunks = []
const re = new RegExp(`<(${DRAWABLES.join('|')})\\b`, 'g')
let m
while ((m = re.exec(treeXml))) {
const chunk = balanced(treeXml, m.index, m[1])
chunks.push({ tag: m[1], xml: chunk })
re.lastIndex = m.index + chunk.length
}
return chunks
}
// ── deck loading (§1) ────────────────────────────────────────────────────────
export async function loadDeck(zip) {
const readPart = (p) => zip.file(p.replace(/^\//, ''))?.async('string')
const presXml = await readPart('ppt/presentation.xml')
if (!presXml) throw new Error('no ppt/presentation.xml — not a .pptx')
const presRels = await readRels(zip, 'ppt/presentation.xml')
const sldSz = firstEl(presXml, 'p:sldSz')
const cx = sldSz ? numAttr(sldSz.tag, 'cx') : 12192000
const cy = sldSz ? numAttr(sldSz.tag, 'cy') : 6858000
const defaultTextStyle = firstEl(presXml, 'p:defaultTextStyle')?.inner ?? ''
const slides = []
for (const mm of presXml.matchAll(/<p:sldId\b[^>]*r:id="(rId\d+)"/g)) {
const target = presRels[mm[1]]?.target
if (!target || !zip.file(target)) continue
const xml = await readPart(target)
const rels = await readRels(zip, target)
// layout
const layoutRel = Object.values(rels).find((r) => /slideLayout/.test(r.type ?? '') || /slideLayouts\//.test(r.target))
let layout = null
if (layoutRel && zip.file(layoutRel.target)) {
const lXml = await readPart(layoutRel.target)
const lRels = await readRels(zip, layoutRel.target)
const masterRel = Object.values(lRels).find((r) => /slideMasters\//.test(r.target))
let master = null
if (masterRel && zip.file(masterRel.target)) {
const mXml = await readPart(masterRel.target)
const mRels = await readRels(zip, masterRel.target)
const themeRel = Object.values(mRels).find((r) => /theme\//.test(r.target))
const themeXml = themeRel && zip.file(themeRel.target) ? await readPart(themeRel.target) : ''
master = { xml: mXml, themeXml }
}
layout = { xml: lXml, master }
}
slides.push({ path: target, xml, rels, layout })
}
return { zip, presXml, defaultTextStyle, size: { cx, cy }, slides }
}
// ── placeholder inventories (§3.2, §4.2) ─────────────────────────────────────
const PH_CLASS = (type) => {
if (type === 'title' || type === 'ctrTitle') return 'title'
if (['ftr', 'sldNum', 'dt', 'hdr'].includes(type)) return 'chrome'
return 'body' // body, subTitle, obj, chart, tbl, dgm, media, clipArt, missing
}
function phInventory(partXml) {
if (!partXml) return []
const tree = firstEl(partXml, 'p:spTree')
if (!tree) return []
const out = []
for (const { tag, xml } of topLevelChunks(tree.inner)) {
if (tag !== 'p:sp') continue
const ph = firstEl(xml, 'p:ph')
if (!ph) continue
const type = attr(ph.tag, 'type') ?? ''
const idx = attr(ph.tag, 'idx') ?? '0'
const spPr = firstEl(xml, 'p:spPr')
const xfrmEl = spPr ? firstEl(spPr.inner, 'a:xfrm') : null
const lst = firstEl(xml, 'a:lstStyle')
out.push({ type, idx, cls: PH_CLASS(type), xfrm: xfrmEl, lstStyle: lst?.inner ?? '' })
}
return out
}
function matchPh(inventory, type, idx) {
return inventory.find((p) => p.idx === idx) ?? inventory.find((p) => p.cls === PH_CLASS(type)) ?? null
}
// ── color resolution (§5) ────────────────────────────────────────────────────
const PRST_COLORS = {
black: '000000', white: 'FFFFFF', red: 'FF0000', green: '008000', blue: '0000FF',
yellow: 'FFFF00', cyan: '00FFFF', magenta: 'FF00FF', gray: '808080', grey: '808080',
darkGray: 'A9A9A9', lightGray: 'D3D3D3', orange: 'FFA500', purple: '800080',
}
const clamp255 = (v) => Math.max(0, Math.min(255, Math.round(v)))
const hex2 = (v) => clamp255(v).toString(16).padStart(2, '0').toUpperCase()
function rgbToHsl(r, g, b) {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
const l = (max + min) / 2
if (max === min) return [0, 0, l]
const d = max - min
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
let h
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
else if (max === g) h = ((b - r) / d + 2) / 6
else h = ((r - g) / d + 4) / 6
return [h, s, l]
}
function hslToRgb(h, s, l) {
if (s === 0) { const v = l * 255; return [v, v, v] }
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
const p = 2 * l - q
const f = (t) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
return [f(h + 1 / 3) * 255, f(h) * 255, f(h - 1 / 3) * 255]
}
// theme clrScheme slot → hex
function themeSlot(themeXml, slot) {
const el = firstEl(themeXml, `a:${slot}`)
if (!el) return null
const srgb = firstEl(el.inner, 'a:srgbClr')
if (srgb) return attr(srgb.tag, 'val')?.toUpperCase() ?? null
const sys = firstEl(el.inner, 'a:sysClr')
if (sys) return (attr(sys.tag, 'lastClr') ?? (attr(sys.tag, 'val') === 'window' ? 'FFFFFF' : '000000')).toUpperCase()
return null
}
/** Resolve one color CONTAINER (xml holding srgbClr/schemeClr/sysClr/prstClr)
* → { hex:"#RRGGBB", opacity, viaTheme, flags[] } or null. `ctx` carries
* themeXml + clrMap; `phClrHex` substitutes <a:schemeClr val="phClr">. */
function resolveColor(containerXml, ctx, phClrHex) {
const flags = []
let base = null
let clrEl = null
let viaTheme = false
for (const name of ['a:srgbClr', 'a:schemeClr', 'a:sysClr', 'a:prstClr']) {
clrEl = firstEl(containerXml, name)
if (!clrEl) continue
if (name === 'a:srgbClr') base = attr(clrEl.tag, 'val')?.toUpperCase()
else if (name === 'a:sysClr') base = (attr(clrEl.tag, 'lastClr') ?? (attr(clrEl.tag, 'val') === 'window' ? 'FFFFFF' : '000000')).toUpperCase()
else if (name === 'a:prstClr') {
const nm = attr(clrEl.tag, 'val')
base = PRST_COLORS[nm]
if (!base) { base = '808080'; flags.push(`color-approx:prst-${nm}`) }
} else {
// schemeClr
viaTheme = true
let slot = attr(clrEl.tag, 'val')
if (slot === 'phClr') {
if (!phClrHex) { flags.push('phClr-outside-style-ref'); base = '000000' }
else base = phClrHex.replace('#', '')
} else {
if (['bg1', 'tx1', 'bg2', 'tx2'].includes(slot)) slot = ctx.clrMap[slot] ?? slot
base = themeSlot(ctx.themeXml, slot)
if (!base) { flags.push(`color-missing:${slot}`); base = '000000' }
}
}
break
}
if (!clrEl || base == null) return null
let [r, g, b] = [parseInt(base.slice(0, 2), 16), parseInt(base.slice(2, 4), 16), parseInt(base.slice(4, 6), 16)]
let opacity = 1
// transforms in document order (§5.2); lumMod+lumOff apply as ONE step
let pendingLumMod = null
const transforms = [...clrEl.inner.matchAll(/<a:(\w+) val="(-?\d+)"\/>/g)]
for (let t = 0; t < transforms.length; t++) {
const [, name, valS] = transforms[t]
const f = Number(valS) / 100000
if (name === 'shade') { r *= f; g *= f; b *= f }
else if (name === 'tint') { r = r * f + 255 * (1 - f); g = g * f + 255 * (1 - f); b = b * f + 255 * (1 - f) }
else if (name === 'lumMod') pendingLumMod = f
else if (name === 'lumOff') {
const [h, s, l] = rgbToHsl(clamp255(r), clamp255(g), clamp255(b))
;[r, g, b] = hslToRgb(h, s, Math.max(0, Math.min(1, l * (pendingLumMod ?? 1) + f)))
pendingLumMod = null
} else if (name === 'satMod') {
const [h, s, l] = rgbToHsl(clamp255(r), clamp255(g), clamp255(b))
;[r, g, b] = hslToRgb(h, Math.max(0, Math.min(1, s * f)), l)
} else if (name === 'alpha') opacity = f
else flags.push(`color-transform-approx:${name}`)
}
if (pendingLumMod !== null) {
const [h, s, l] = rgbToHsl(clamp255(r), clamp255(g), clamp255(b))
;[r, g, b] = hslToRgb(h, s, Math.max(0, Math.min(1, l * pendingLumMod)))
}
return { hex: `#${hex2(r)}${hex2(g)}${hex2(b)}`, opacity, viaTheme, flags }
}
// ── theme format-scheme lookups (§4.5) ───────────────────────────────────────
function fmtSchemeEntry(themeXml, listName, index0) {
const list = firstEl(themeXml, `a:${listName}`)
if (!list) return null
const entries = []
const re = /<a:(solidFill|gradFill|blipFill|pattFill|noFill|ln)\b/g
let m
while ((m = re.exec(list.inner))) {
const chunk = balanced(list.inner, m.index, `a:${m[1]}`)
entries.push(chunk)
re.lastIndex = m.index + chunk.length
}
return entries[index0] ?? null
}
// ── fonts (§4.4) ─────────────────────────────────────────────────────────────
function resolveFontFace(typeface, themeXml) {
if (!typeface) return null
if (typeface === '+mj-lt' || typeface === '+mn-lt') {
const block = firstEl(themeXml, typeface === '+mj-lt' ? 'a:majorFont' : 'a:minorFont')
const latin = block ? firstEl(block.inner, 'a:latin') : null
return { name: latin ? attr(latin.tag, 'typeface') ?? 'Calibri' : 'Calibri', viaTheme: true }
}
return { name: typeface, viaTheme: false }
}
// ── the run-property cascade (§4.2) ──────────────────────────────────────────
// Sources are { xml, level } where xml is a pPr-like blob whose defRPr carries
// run props (step 1 passes the rPr itself). Extractors return undefined = miss.
const LEVEL_RANK = { slide: 0, layout: 1, master: 2, theme: 3, default: 4 }
function cascade(sources, extract) {
for (const s of sources) {
if (!s || !s.xml) continue
const v = extract(s.xml)
if (v !== undefined) return { value: v, level: s.level }
}
return { value: undefined, level: null }
}
const lvlPPr = (styleBlock, level) => firstEl(styleBlock ?? '', `a:lvl${level + 1}pPr`)?.inner ?? (level === 0 ? undefined : undefined)
const defRPrOf = (pPrXml) => (pPrXml ? firstEl(pPrXml, 'a:defRPr') : null)
// ── main per-slide resolution ────────────────────────────────────────────────
export function resolveSlide(model, slideIndex) {
const slide = model.slides[slideIndex]
if (!slide) throw new Error(`no slide ${slideIndex}`)
const flags = []
// canvas scale (§2)
const { cx, cy } = model.size
const scale = Math.min((1280 * EMU_PER_PX) / cx, (720 * EMU_PER_PX) / cy)
const offX = (1280 - (cx / EMU_PER_PX) * scale) / 2
const offY = (720 - (cy / EMU_PER_PX) * scale) / 2
if (cx !== 12192000 || cy !== 6858000) flags.push('letterboxed')
const toPx = (emuX, emuY, emuW, emuH) => [
Math.round((emuX / EMU_PER_PX) * scale * 10 + offX * 10) / 10,
Math.round((emuY / EMU_PER_PX) * scale * 10 + offY * 10) / 10,
Math.round((emuW / EMU_PER_PX) * scale * 10) / 10,
Math.round((emuH / EMU_PER_PX) * scale * 10) / 10,
]
// color-map: master clrMap, layout may override (§1)
const masterXml = slide.layout?.master?.xml ?? ''
const themeXml = slide.layout?.master?.themeXml ?? ''
const clrMap = {}
const mapTag = firstEl(masterXml, 'p:clrMap')?.tag ?? ''
for (const mm of mapTag.matchAll(/(\w+)="(\w+)"/g)) clrMap[mm[1]] = mm[2]
const ovr = firstEl(slide.layout?.xml ?? '', 'p:clrMapOvr')
if (ovr && /overrideClrMapping/.test(ovr.inner)) {
const oTag = firstEl(ovr.inner, 'a:overrideClrMapping')?.tag ?? ''
for (const mm of oTag.matchAll(/(\w+)="(\w+)"/g)) clrMap[mm[1]] = mm[2]
}
const ctx = { themeXml, clrMap }
const layoutPhs = phInventory(slide.layout?.xml)
const masterPhs = phInventory(masterXml)
const txStyles = firstEl(masterXml, 'p:txStyles')?.inner ?? ''
const masterBlocks = {
title: firstEl(txStyles, 'p:titleStyle')?.inner ?? '',
body: firstEl(txStyles, 'p:bodyStyle')?.inner ?? '',
chrome: firstEl(txStyles, 'p:otherStyle')?.inner ?? '',
other: firstEl(txStyles, 'p:otherStyle')?.inner ?? '',
}
const elements = []
let elN = 0
const tree = firstEl(slide.xml, 'p:spTree')
const chunks = tree ? topLevelChunks(tree.inner) : []
const pushRescued = (xml, err) => {
const texts = [...xml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => decodeXml(m[1]))
elements.push({
id: `s${slideIndex + 1}e${++elN}`,
kind: 'rescued',
bbox: [40, 180, 1200, 500],
z: elements.length,
rot: 0,
flip: [false, false],
style: {},
content: texts.join('\n'),
sourceLevel: 'default',
confidence: 'rescued',
flags: [`parse-error:${String(err.message ?? err).slice(0, 80)}`],
})
}
const resolveShape = (xml, group) => {
const elFlags = []
let deepest = 'slide'
const bump = (level) => {
if (level && LEVEL_RANK[level] > LEVEL_RANK[deepest]) deepest = level
}
const ph = firstEl(xml, 'p:ph')
const phType = ph ? attr(ph.tag, 'type') ?? '' : null
const phIdx = ph ? attr(ph.tag, 'idx') ?? '0' : null
const layoutPh = ph ? matchPh(layoutPhs, phType, phIdx) : null
const masterPh = ph ? matchPh(masterPhs, phType, phIdx) : null
// geometry (§3.1–3.3)
const spPr = firstEl(xml, 'p:spPr') ?? firstEl(xml, 'p:grpSpPr')
let xfrmEl = spPr ? firstEl(spPr.inner, 'a:xfrm') : null
let geoLevel = 'slide'
let confidence = 'resolved'
if (!xfrmEl && ph) {
if (layoutPh?.xfrm) { xfrmEl = layoutPh.xfrm; geoLevel = 'layout' }
else if (masterPh?.xfrm) { xfrmEl = masterPh.xfrm; geoLevel = 'master' }
}
let bbox
let rot = 0
let flip = [false, false]
if (xfrmEl) {
const off = firstEl(xfrmEl.inner, 'a:off')
const ext = firstEl(xfrmEl.inner, 'a:ext')
let x = numAttr(off?.tag ?? '', 'x') ?? 0
let y = numAttr(off?.tag ?? '', 'y') ?? 0
let w = numAttr(ext?.tag ?? '', 'cx') ?? 0
let h = numAttr(ext?.tag ?? '', 'cy') ?? 0
if (group) {
x = group.offX + (x - group.chOffX) * group.scaleX
y = group.offY + (y - group.chOffY) * group.scaleY
w = w * group.scaleX
h = h * group.scaleY
}
bbox = toPx(x, y, w, h)
rot = (numAttr(xfrmEl.tag, 'rot') ?? 0) / 60000
flip = [attr(xfrmEl.tag, 'flipH') === '1', attr(xfrmEl.tag, 'flipV') === '1']
bump(geoLevel)
} else {
elFlags.push('ph-unmatched')
bbox = PH_CLASS(phType ?? '') === 'title' ? [40, 40, 1200, 120] : [40, 180, 1200, 500]
confidence = 'fallback'
}
// style refs (§4.5)
const styleEl = firstEl(xml, 'p:style')
let styleRefFill = null
let styleRefStroke = null
let styleRefFont = null
if (styleEl) {
const fillRef = firstEl(styleEl.inner, 'a:fillRef')
if (fillRef) {
const idx = Number(attr(fillRef.tag, 'idx') ?? 0)
const phColor = resolveColor(fillRef.inner, ctx, null)
if (idx >= 1) {
const def = idx >= 1001 ? fmtSchemeEntry(themeXml, 'bgFillStyleLst', idx - 1001) : fmtSchemeEntry(themeXml, 'fillStyleLst', idx - 1)
if (def) {
const c = resolveColor(def, ctx, phColor?.hex ?? null)
if (c) { styleRefFill = c; elFlags.push(...c.flags) }
}
}
}
const lnRef = firstEl(styleEl.inner, 'a:lnRef')
if (lnRef) {
const idx = Number(attr(lnRef.tag, 'idx') ?? 0)
const phColor = resolveColor(lnRef.inner, ctx, null)
if (idx >= 1) {
const def = fmtSchemeEntry(themeXml, 'lnStyleLst', idx - 1)
if (def) styleRefStroke = resolveColor(def, ctx, phColor?.hex ?? null)
}
}
const fontRef = firstEl(styleEl.inner, 'a:fontRef')
if (fontRef) {
const which = attr(fontRef.tag, 'idx')
const face = resolveFontFace(which === 'major' ? '+mj-lt' : '+mn-lt', themeXml)
styleRefFont = { face, color: resolveColor(fontRef.inner, ctx, null) }
}
}
// explicit fill/stroke beat the style ref (§4.5)
let fill = null
let stroke = null
let opacity = 1
if (spPr) {
const solid = firstEl(spPr.inner, 'a:solidFill')
if (solid) {
const c = resolveColor(solid.inner, ctx, null)
if (c) { fill = c.hex; opacity = c.opacity; if (c.viaTheme) bump('theme'); elFlags.push(...c.flags) }
} else if (/<a:noFill\/>/.test(spPr.inner)) fill = null
else if (styleRefFill) { fill = styleRefFill.hex; opacity = styleRefFill.opacity; bump('theme') }
const ln = firstEl(spPr.inner, 'a:ln')
if (ln) {
const lc = firstEl(ln.inner, 'a:solidFill')
const c = lc ? resolveColor(lc.inner, ctx, null) : null
if (c) stroke = c.hex
} else if (styleRefStroke) { stroke = styleRefStroke.hex; bump('theme') }
}
// text (§4.2–§4.4)
const txBody = firstEl(xml, 'p:txBody')
let content = ''
let style = {
fill,
opacity,
stroke,
font: null,
size: null,
bold: false,
color: null,
align: 'l',
bullet: { kind: 'none' },
}
if (txBody) {
const bodyPr = firstEl(txBody.inner, 'a:bodyPr')
const autofit = bodyPr ? firstEl(bodyPr.inner ?? '', 'a:normAutofit') ?? firstEl(txBody.inner, 'a:normAutofit') : null
const fontScale = autofit ? (numAttr(autofit.tag, 'fontScale') ?? 100000) / 100000 : 1
if (autofit && fontScale !== 1) elFlags.push('autofit-applied')
const shapeLst = firstEl(txBody.inner, 'a:lstStyle')?.inner ?? ''
const paras = []
const paraRe = /<a:p>([\s\S]*?)<\/a:p>/g
let pm
let firstStyle = null
while ((pm = paraRe.exec(txBody.inner))) {
const pXml = pm[1]
const pPr = firstEl(pXml, 'a:pPr')
const level = pPr ? Number(attr(pPr.tag, 'lvl') ?? 0) : 0
const runTexts = []
const runRe = /<a:r>([\s\S]*?)<\/a:r>/g
let rm
let firstRPr
while ((rm = runRe.exec(pXml))) {
const rXml = rm[1]
if (firstRPr === undefined) firstRPr = firstEl(rXml, 'a:rPr')?.tag ?? null
const t = firstEl(rXml, 'a:t')
runTexts.push(t ? decodeXml(t.inner) : '')
}
// cascade sources for this paragraph's first run (per-property, §4.2)
const rPrFull = firstRPr === undefined ? null : firstEl(pXml, 'a:rPr')
const sources = [
rPrFull ? { xml: `<a:defRPr${rPrFull.tag.slice('<a:rPr'.length)}${rPrFull.inner}</a:defRPr>`, level: 'slide' } : null,
pPr ? { xml: pPr.inner, level: 'slide' } : null,
{ xml: lvlPPr(shapeLst, level), level: 'slide' },
layoutPh ? { xml: lvlPPr(layoutPh.lstStyle, level), level: 'layout' } : null,
masterPh ? { xml: lvlPPr(masterPh.lstStyle, level), level: 'master' } : null,
{ xml: lvlPPr(masterBlocks[ph ? PH_CLASS(phType) : 'other'], level), level: 'master' },
{ xml: lvlPPr(model.defaultTextStyle, level), level: 'default' },
]
const runProp = (extract) =>
cascade(sources, (src) => {
const d = defRPrOf(src) ?? (src.startsWith('<a:defRPr') ? firstEl(src, 'a:defRPr') : null)
const scope = d ? d.tag + d.inner + '</a:defRPr>' : null
return scope ? extract(scope) : undefined
})
const sz = runProp((s) => { const v = numAttr(firstEl(s, 'a:defRPr')?.tag ?? s, 'sz'); return v === undefined ? undefined : v })
const bold = runProp((s) => { const v = attr(firstEl(s, 'a:defRPr')?.tag ?? s, 'b'); return v === undefined ? undefined : v === '1' })
const colorRes = runProp((s) => {
const sf = firstEl(s, 'a:solidFill')
if (!sf) return undefined
return resolveColor(sf.inner, ctx, null) ?? undefined
})
const latin = runProp((s) => {
const l = firstEl(s, 'a:latin')
return l ? attr(l.tag, 'typeface') : undefined
})
const align = pPr ? attr(pPr.tag, 'algn') : undefined
// bullets live on the pPr level itself (§6)
const bullet = cascade(sources.slice(1), (src) => {
if (/<a:buNone\/>/.test(src)) return { kind: 'none' }
const bc = firstEl(src, 'a:buChar')
if (bc) return { kind: 'char', char: attr(bc.tag, 'char') ?? '•', level }
const ba = firstEl(src, 'a:buAutoNum')
if (ba) return { kind: 'autonum', numType: attr(ba.tag, 'type') ?? 'arabicPeriod', level }
return undefined
})
if (!firstStyle) {
const face = resolveFontFace(latin.value ?? null, themeXml)
if (face?.viaTheme || colorRes.value?.viaTheme) bump('theme')
for (const lv of [sz.level, bold.level, colorRes.level, latin.level, bullet.level]) bump(lv)
firstStyle = {
size: Math.round(((sz.value ?? 1800) / 100) * fontScale * 10) / 10,
bold: bold.value ?? false,
color: colorRes.value?.hex ?? (styleRefFont?.color?.hex ?? '#000000'),
font: {
original: face?.name ?? styleRefFont?.face?.name ?? 'Calibri',
mapped: face?.name ?? styleRefFont?.face?.name ?? 'Calibri',
},
align: align ?? 'l',
bullet: bullet.value ?? { kind: 'none' },
}
if (colorRes.value) elFlags.push(...colorRes.value.flags)
}
paras.push(runTexts.join(''))
}
content = paras.join('\n')
if (firstStyle) style = { ...style, ...firstStyle }
}
const kind = ph && PH_CLASS(phType) === 'chrome' ? 'chrome' : txBody && content ? 'text' : 'shape'
const el = {
id: `s${slideIndex + 1}e${++elN}`,
kind,
bbox,
z: elements.length,
rot,
flip,
style,
content,
sourceLevel: deepest,
confidence,
flags: elFlags,
}
if (group?.rot) el.groupRot = group.rot
return el
}
const walk = (chunkList, group) => {
for (const { tag, xml } of chunkList) {
try {
if (tag === 'p:sp' || tag === 'p:cxnSp') {
elements.push(resolveShape(xml, group))
} else if (tag === 'p:pic') {
const spPr = firstEl(xml, 'p:spPr')
const xfrmEl = spPr ? firstEl(spPr.inner, 'a:xfrm') : null
const off = xfrmEl ? firstEl(xfrmEl.inner, 'a:off') : null
const ext = xfrmEl ? firstEl(xfrmEl.inner, 'a:ext') : null
let x = numAttr(off?.tag ?? '', 'x') ?? 0
let y = numAttr(off?.tag ?? '', 'y') ?? 0
let w = numAttr(ext?.tag ?? '', 'cx') ?? 0
let h = numAttr(ext?.tag ?? '', 'cy') ?? 0
if (group) {
x = group.offX + (x - group.chOffX) * group.scaleX
y = group.offY + (y - group.chOffY) * group.scaleY
w *= group.scaleX
h *= group.scaleY
}
const embed = xml.match(/(?:r:embed|r:link)="(rId\d+)"/)?.[1]
elements.push({
id: `s${slideIndex + 1}e${++elN}`,
kind: 'image',
bbox: toPx(x, y, w, h),
z: elements.length,
rot: 0,
flip: [false, false],
style: {},
imageRef: embed ? slide.rels[embed]?.target ?? embed : null,
sourceLevel: 'slide',
confidence: 'resolved',
flags: [],
})
} else if (tag === 'p:graphicFrame') {
const xfrmEl = firstEl(xml, 'p:xfrm')
const off = xfrmEl ? firstEl(xfrmEl.inner, 'a:off') : null
const ext = xfrmEl ? firstEl(xfrmEl.inner, 'a:ext') : null
const bbox = toPx(
numAttr(off?.tag ?? '', 'x') ?? 0,
numAttr(off?.tag ?? '', 'y') ?? 0,
numAttr(ext?.tag ?? '', 'cx') ?? 0,
numAttr(ext?.tag ?? '', 'cy') ?? 0,
)
const base = {
id: `s${slideIndex + 1}e${++elN}`,
bbox,
z: elements.length,
rot: 0,
flip: [false, false],
style: {},
sourceLevel: 'slide',
confidence: 'resolved',
flags: [],
}
if (/graphicData[^>]*uri="[^"]*\/table"/.test(xml)) {
elements.push({ ...base, kind: 'table', data: parseTable(xml, base.flags) })
} else if (/graphicData[^>]*uri="[^"]*\/chart"/.test(xml)) {
const rid = xml.match(/<c:chart\b[^>]*r:id="(rId\d+)"/)?.[1]
const target = rid ? slide.rels[rid]?.target : null
const chartXml = target ? model.chartXmlByPath?.get(target) : null
elements.push({ ...base, kind: 'chart', data: chartXml ? parseChartXml(chartXml) : null, flags: chartXml ? [] : ['chart-cache-missing'] })
} else if (/graphicData[^>]*uri="[^"]*\/diagram"/.test(xml)) {
const dm = xml.match(/r:dm="(rId\d+)"/)?.[1]
const target = dm ? slide.rels[dm]?.target : null
const dgmXml = target ? model.diagramXmlByPath?.get(target) : null
if (dgmXml) elements.push({ ...base, kind: 'diagram', data: parseDiagram(dgmXml, base.flags) })
else elements.push({ ...base, kind: 'diagram', data: null, flags: ['diagram-data-missing'] })
} else {
elements.push({ ...base, kind: 'shape', flags: ['graphicframe-unknown'] })
}
} else if (tag === 'p:grpSp') {
const grpPr = firstEl(xml, 'p:grpSpPr')
const xfrmEl = grpPr ? firstEl(grpPr.inner, 'a:xfrm') : null
if (!xfrmEl) throw new Error('group without xfrm')
const off = firstEl(xfrmEl.inner, 'a:off')
const ext = firstEl(xfrmEl.inner, 'a:ext')
const chOff = firstEl(xfrmEl.inner, 'a:chOff')
const chExt = firstEl(xfrmEl.inner, 'a:chExt')
let g = {
offX: numAttr(off.tag, 'x'),
offY: numAttr(off.tag, 'y'),
scaleX: numAttr(ext.tag, 'cx') / (numAttr(chExt.tag, 'cx') || 1),
scaleY: numAttr(ext.tag, 'cy') / (numAttr(chExt.tag, 'cy') || 1),
chOffX: numAttr(chOff.tag, 'x'),
chOffY: numAttr(chOff.tag, 'y'),
rot: (numAttr(xfrmEl.tag, 'rot') ?? 0) / 60000,
}
// compose with an outer group (§3.3 recursion)
if (group) {
g = {
offX: group.offX + (g.offX - group.chOffX) * group.scaleX,
offY: group.offY + (g.offY - group.chOffY) * group.scaleY,
scaleX: group.scaleX * g.scaleX,
scaleY: group.scaleY * g.scaleY,
chOffX: g.chOffX,
chOffY: g.chOffY,
rot: group.rot + g.rot,
}
}
if (g.rot) flags.push('group-rotation')
const innerTree = xml.slice(xml.indexOf('</p:grpSpPr>') + '</p:grpSpPr>'.length, xml.lastIndexOf('</p:grpSp>'))
walk(topLevelChunks(innerTree), g)
}
} catch (err) {
pushRescued(xml, err)
}
}
}
walk(chunks)
return {
slide: slideIndex + 1,
size: [1280, 720],
elements,
readingOrder: readingOrder(elements),
notes: slide.notes ?? '',
flags,
}
}
// tables (§6): grid + spans; continuation cells skipped
function parseTable(frameXml, flags) {
const tbl = firstEl(frameXml, 'a:tbl')
if (!tbl) return null
if (/tableStyleId/.test(tbl.inner)) flags.push('table-style-partial')
const grid = [...tbl.inner.matchAll(/<a:gridCol w="(\d+)"\/?>/g)].map((m) => px(Number(m[1])))
const rows = []
const trRe = /<a:tr\b[^>]*>/g
let tm
while ((tm = trRe.exec(tbl.inner))) {
const trXml = balanced(tbl.inner, tm.index, 'a:tr')
const cells = []
const tcRe = /<a:tc\b[^>]*>/g
let cm
while ((cm = tcRe.exec(trXml))) {
const tcXml = balanced(trXml, cm.index, 'a:tc')
const openTag = tcXml.match(/^<a:tc\b[^>]*>/)?.[0] ?? ''
tcRe.lastIndex = cm.index + tcXml.length
if (attr(openTag, 'hMerge') === '1' || attr(openTag, 'vMerge') === '1') continue
const texts = [...tcXml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => decodeXml(m[1]))
const cellObj = { text: texts.join(' ').trim() }
const gs = attr(openTag, 'gridSpan')
const rs = attr(openTag, 'rowSpan')
if (gs) cellObj.colspan = Number(gs)
if (rs) cellObj.rowspan = Number(rs)
cells.push(cellObj)
}
rows.push({ cells })
}
return { grid, rows }
}
// SmartArt (§6): dgm:pt nodes (skip transition points) + dgm:cxn edges → tree
function parseDiagram(dgmXml, flags) {
const nodes = new Map()
const ptRe = /<dgm:pt\b[^>]*>/g
let m
while ((m = ptRe.exec(dgmXml))) {
const open = m[0]
const type = attr(open, 'type')
const id = attr(open, 'modelId')
const chunk = balanced(dgmXml, m.index, 'dgm:pt')
ptRe.lastIndex = m.index + chunk.length
if (type === 'parTrans' || type === 'sibTrans') continue
const texts = [...chunk.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((mm) => decodeXml(mm[1]))
nodes.set(id, { id, text: texts.join(' ').trim(), children: [], isDoc: type === 'doc' })
}
const edges = []
for (const mm of dgmXml.matchAll(/<dgm:cxn\b[^>]*\bsrcId="([^"]+)"[^>]*\bdestId="([^"]+)"[^>]*>/g)) {
const srcOrd = Number(attr(mm[0], 'srcOrd') ?? 0)
edges.push({ src: mm[1], dest: mm[2], srcOrd })
}
edges.sort((a, b) => a.srcOrd - b.srcOrd)
const hasParent = new Set()
for (const e of edges) {
const p = nodes.get(e.src)
const c = nodes.get(e.dest)
if (p && c) {
p.children.push(c)
hasParent.add(c.id)
}
}
// roots = non-doc nodes without parents, or the doc node's children
let roots = [...nodes.values()].filter((n) => !n.isDoc && !hasParent.has(n.id))
const doc = [...nodes.values()].find((n) => n.isDoc)
if (!roots.length && doc) roots = doc.children
if (doc) roots = roots.filter((n) => !n.isDoc)
const depth = (n) => (n.children.length ? 1 + Math.max(...n.children.map(depth)) : 0)
const maxDepth = roots.length ? Math.max(...roots.map(depth)) : 0
const strip = (n) => ({ id: n.id, text: n.text, children: n.children.map(strip) })
if (!roots.length) flags.push('smartart-layout-approx')
return { kind: maxDepth > 1 || (roots.length === 1 && roots[0].children.length) ? 'org' : 'flow', nodes: roots.map(strip) }
}
// reading order (§8): greedy columns, full-width bands
function readingOrder(elements) {
const els = elements.filter((e) => e.kind !== 'chrome')
if (els.length <= 1) return els.map((e) => e.id)
const bands = els.filter((e) => e.bbox[2] >= 1280 * 0.7)
const rest = els.filter((e) => !bands.includes(e))
const columns = []
for (const el of [...rest].sort((a, b) => a.bbox[0] - b.bbox[0])) {
const [x, , w] = el.bbox
let placed = false
for (const col of columns) {
const overlap = Math.min(x + w, col.right) - Math.max(x, col.left)
if (overlap >= 0.3 * Math.min(w, col.right - col.left)) {
col.items.push(el)
col.left = Math.min(col.left, x)
col.right = Math.max(col.right, x + w)
placed = true
break
}
}
if (!placed) columns.push({ left: x, right: x + w, items: [el] })
}
if (!bands.length && columns.length <= 1) {
return els.sort((a, b) => a.bbox[1] - b.bbox[1] || a.bbox[0] - b.bbox[0]).map((e) => e.id)
}
const sortedBands = bands.sort((a, b) => a.bbox[1] - b.bbox[1])
const cuts = [...sortedBands.map((b) => b.bbox[1]), Infinity]
const out = []
let prevCut = -Infinity
for (let i = 0; i < cuts.length; i++) {
if (i < sortedBands.length) out.push(sortedBands[i].id)
const lo = i < sortedBands.length ? sortedBands[i].bbox[1] : prevCut
const hi = cuts[i + 1] ?? Infinity
for (const col of columns) {
for (const el of col.items.sort((a, b) => a.bbox[1] - b.bbox[1])) {
if (el.bbox[1] >= lo && el.bbox[1] < hi && !out.includes(el.id)) out.push(el.id)
}
}
prevCut = hi
}
for (const el of els) if (!out.includes(el.id)) out.push(el.id)
return out
}
// optional async caches the caller can prime (charts + diagrams)
export async function primeIslandCaches(model) {
model.chartXmlByPath = new Map()
model.diagramXmlByPath = new Map()
for (const slide of model.slides) {
for (const rel of Object.values(slide.rels)) {
if (/charts\/chart\d+\.xml$/.test(rel.target) && model.zip.file(rel.target)) {
model.chartXmlByPath.set(rel.target, await model.zip.file(rel.target).async('string'))
}
if (/diagrams\/data\d+\.xml$/.test(rel.target) && model.zip.file(rel.target)) {
model.diagramXmlByPath.set(rel.target, await model.zip.file(rel.target).async('string'))
}
}
}
return model
}
/**
* Shared OOXML + HTML helpers for the pptx2unpaper importer (E8.1).
*
* Runtime-neutral — zero deps beyond jszip (passed in by the caller). No DOM,
* no network, and since the `/import` web surface (2026-07-18) NO Node-only
* APIs either: sha256 is pure JS (hex-identical to node:crypto — p2h:verify
* would fail on every fixture hash otherwise), zip paths use local POSIX
* helpers (zip paths are always forward-slash), image sizes read through a
* DataView, and base64 uses Buffer only when it exists. The same bytes run in
* the CLI, the MCP server, and the browser bundle.
*/
// ── POSIX path ops for zip-internal paths (node:path replacement) ────────────
export const pDirname = (p) => (p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '.')
export const pBasename = (p) => p.slice(p.lastIndexOf('/') + 1)
export function pNormalize(p) {
const out = []
for (const seg of p.split('/')) {
if (!seg || seg === '.') continue
if (seg === '..' && out.length && out[out.length - 1] !== '..') out.pop()
else out.push(seg)
}
return out.join('/')
}
export const pJoin = (...parts) => pNormalize(parts.filter(Boolean).join('/'))
// ── XML text ─────────────────────────────────────────────────────────────────
export const decodeXml = (s) =>
s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&apos;/g, "'").replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(+d))
.replace(/&amp;/g, '&')
export const escHtml = (s) =>
String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// For values that land inside a double-quoted attribute.
export const escAttr = (s) => escHtml(s).replace(/"/g, '&quot;')
// All <a:t>…</a:t> text inside a chunk, joined (runs in one paragraph = one line).
export function paraText(paraXml) {
const runs = [...paraXml.matchAll(/<a:t>([\s\S]*?)<\/a:t>/g)].map((m) => decodeXml(m[1]))
return runs.join('').replace(/\s+/g, ' ').trim()
}
// A txBody → { lines:[…], maxSz } where each <a:p> is a line.
export function readTxBody(txXml) {
const paras = [...txXml.matchAll(/<a:p>([\s\S]*?)<\/a:p>/g)].map((m) => m[1])
const lines = paras.map(paraText).filter(Boolean)
const sizes = [...txXml.matchAll(/sz="(\d+)"/g)].map((m) => +m[1])
const maxSz = sizes.length ? Math.max(...sizes) / 100 : 12
return { lines, maxSz }
}
// ── Relationships: rId → { target, raw } (target resolved to a package path) ──
export async function readRels(zip, partPath) {
const dir = pDirname(partPath)
const relsPath = pJoin(dir, '_rels', pBasename(partPath) + '.rels')
const file = zip.file(relsPath)
if (!file) return {}
const xml = await file.async('string')
const map = {}
for (const r of xml.matchAll(/<Relationship\b[^>]*Id="([^"]+)"[^>]*Target="([^"]+)"[^>]*\/?>/g)) {
// External targets (TargetMode="External") stay as-is. Internal targets are
// usually relative to the part's folder, but some writers (pptxgenjs among
// them) emit ABSOLUTE package paths ("/ppt/charts/chart1.xml") — those
// resolve from the package root, not the part's folder.
const target = /TargetMode="External"/.test(r[0])
? r[2]
: r[2].startsWith('/')
? r[2].replace(/^\/+/, '')
: pJoin(dir, r[2])
map[r[1]] = { target, raw: r[0] }
}
return map
}
// ── Media ────────────────────────────────────────────────────────────────────
export const MIME = {
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
svg: 'image/svg+xml', webp: 'image/webp', bmp: 'image/bmp', tiff: 'image/tiff',
emf: 'image/emf', wmf: 'image/wmf',
}
export const VECTOR_EXTS = new Set(['emf', 'wmf'])
export const extOf = (p) => (p.match(/\.(\w+)$/)?.[1] || '').toLowerCase()
export const mimeOf = (p) => MIME[extOf(p)] || 'application/octet-stream'
// Pure-JS SHA-256 (FIPS 180-4), synchronous, hex out. Registry hashes are the
// importer's dedup + rehydrate identity, so this MUST stay hex-identical to
// node:crypto — asserted by the p2h fixtures (every stored sha would drift).
const SHA_K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0
export function sha256(input) {
const data = input instanceof Uint8Array ? input : new Uint8Array(input)
const len = data.length
const padded = new Uint8Array((((len + 8) >> 6) + 1) << 6)
padded.set(data)
padded[len] = 0x80
const dv = new DataView(padded.buffer)
dv.setUint32(padded.length - 8, Math.floor(len / 0x20000000))
dv.setUint32(padded.length - 4, (len << 3) >>> 0)
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
const w = new Uint32Array(64)
for (let off = 0; off < padded.length; off += 64) {
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + (i << 2))
for (let i = 16; i < 64; i++) {
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3)
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10)
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
}
let [a, b, c, d, e, f, g, hh] = h
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)
const ch = (e & f) ^ (~e & g)
const t1 = (hh + S1 + ch + SHA_K[i] + w[i]) >>> 0
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)
const maj = (a & b) ^ (a & c) ^ (b & c)
const t2 = (S0 + maj) >>> 0
hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0
}
h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0
h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0
}
return [...h].map((x) => x.toString(16).padStart(8, '0')).join('')
}
// Bytes → base64 without assuming Buffer: Node's fast path when present,
// chunked btoa in the browser bundle (32 KB chunks keep the arg list sane).
export function toBase64(bytes) {
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64')
let bin = ''
for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000))
return btoa(bin)
}
// Mirrors LOGO_MAX_CHARS in src/lib/unpaper.ts — a brand-kit logo data-URI over
// this many chars is rejected by parseBrandKit, so we emit palette-only instead.
export const LOGO_MAX_CHARS = 280_000
export const EMU_PER_PX = 9525 // 914400 EMU/inch ÷ 96 dpi
export const emuToPx = (emu) => Math.round(Number(emu) / EMU_PER_PX)
// Intrinsic pixel size from image bytes — enough formats for real decks; anything
// unknown returns null and the caller falls back to the shape's box (EMU→px).
export function imageSize(buf) {
if (!buf || buf.length < 24) return null
// DataView instead of Buffer.readUInt* so a plain Uint8Array (the browser
// path) reads identically; Buffer is a Uint8Array, so Node hits this too.
const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
// PNG: 8-byte sig, then IHDR at offset 16 (width @16, height @20, big-endian).
if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
return { w: dv.getUint32(16), h: dv.getUint32(20) }
}
// GIF: width/height little-endian at offset 6/8.
if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) {
return { w: dv.getUint16(6, true), h: dv.getUint16(8, true) }
}
// JPEG: scan segments for a Start-Of-Frame marker (0xFFC0..0xFFCF except C4/C8/CC).
if (buf[0] === 0xff && buf[1] === 0xd8) {
let i = 2
while (i + 9 < buf.length) {
if (buf[i] !== 0xff) { i++; continue }
const marker = buf[i + 1]
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
return { h: dv.getUint16(i + 5), w: dv.getUint16(i + 7) }
}
const len = dv.getUint16(i + 2)
if (len < 2) break
i += 2 + len
}
}
// BMP: DIB header width/height little-endian at offset 18/22.
if (buf[0] === 0x42 && buf[1] === 0x4d) {
return { w: dv.getInt32(18, true), h: Math.abs(dv.getInt32(22, true)) }
}
return null
}
// Zero-padded stable id, e.g. img_001.
export const imgId = (n) => `img_${String(n).padStart(3, '0')}`
// ISO date (yyyy-mm-dd) for provenance stamps — deterministic for fixtures via
// UNPAPER_NOW (the typeof guard keeps the browser bundle process-free).
export const isoDate = () =>
((typeof process !== 'undefined' && process.env?.UNPAPER_NOW) || new Date().toISOString()).slice(0, 10)
/**
* templates.mjs — render the abstract slide element list into unPaper HTML.
*
* Two documents share one renderer; only images differ:
* deck.unpaper.html — content images inline as data-URIs (self-contained law)
* deck.ai.html — content images as <figure data-unpaper-image> placeholders
*
* Charts render identically in both: a `data-unpaper-chart` figure (the live
* forward-engine contract) wrapping a light SVG placeholder, so the deck
* round-trips into an EDITABLE PowerPoint chart with no AI in the loop.
*/
import { escHtml, escAttr } from './shared.mjs'
// JSON → a value safe inside a single-quoted HTML attribute (browser decodes it
// back to the exact JSON before JSON.parse in the forward engine).
const attrJson = (obj) =>
JSON.stringify(obj).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/'/g, '&#39;')
function chartSvg(spec, w, h) {
const width = Math.min(Math.max(w, 240), 1120)
const height = Math.min(Math.max(h, 160), 560)
return (
`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" ` +
`aria-label="${escAttr(spec.type)} chart placeholder">` +
`<rect x="0" y="0" width="${width}" height="${height}" fill="#f0eee7"/>` +
`<text x="${width / 2}" y="${height / 2}" text-anchor="middle" fill="#9a988f" ` +
`font-family="monospace" font-size="14">${escHtml(spec.type)} chart · ${spec.series.length} series</text>` +
`</svg>`
)
}
function renderEl(el, mode, imageIndex) {
switch (el.t) {
case 'title':
return ` <h2 class="title">${escHtml(el.text)}</h2>`
case 'p':
return ` <p>${escHtml(el.text)}</p>`
case 'note':
return ` <p><em>${escHtml(el.text)}</em></p>`
case 'ul':
return ' <ul>\n' + el.items.map((l) => ` <li>${escHtml(l)}</li>`).join('\n') + '\n </ul>'
case 'table': {
const rows = el.rows.map((cells, ri) => {
const tag = ri === 0 ? 'th' : 'td'
return ' <tr>' + cells.map((c) => `<${tag}>${escHtml(c)}</${tag}>`).join('') + '</tr>'
}).join('\n')
return ' <table>\n' + rows + '\n </table>'
}
case 'chart':
return (
` <figure class="chart" data-unpaper-chart='${attrJson(el.spec)}' ` +
`style="width:${Math.min(el.w, 1120)}px;height:${Math.min(el.h, 560)}px">` +
chartSvg(el.spec, el.w, el.h) + `</figure>`
)
case 'chart-dead':
return ` <p><em>(A chart lived here but its data could not be read. If the values are legible in the source, ask the AI to re-create it as a <code>data-unpaper-chart</code>.)</em></p>`
case 'vector':
return ` <figure class="vector"><figcaption>${escHtml(el.alt)} — unsupported vector format (EMF/WMF); re-create it if it carries meaning.</figcaption></figure>`
case 'image':
if (mode === 'ai') {
return (
` <figure data-unpaper-image="${el.id}" data-w="${el.w}" data-h="${el.h}"` +
(el.alt ? ` data-alt="${escAttr(el.alt)}"` : '') +
`>[image ${el.id}${el.alt ? ` — ${escHtml(el.alt)}` : ''}]</figure>`
)
} else {
const r = imageIndex.get(el.id)
if (!r) return ` <figure data-unpaper-image="${el.id}"></figure>`
return (
` <img alt="${escAttr(el.alt || 'imported image')}" ` +
`src="data:${r.mime};base64,${r.b64}" style="max-width:640px;max-height:360px">`
)
}
default:
return ''
}
}
function renderSlides(slides, mode, imageIndex) {
return slides.map(({ els, notes }, i) => {
const parts = els.map((e) => renderEl(e, mode, imageIndex)).filter(Boolean)
const notesHtml = notes ? `\n <aside class="notes">${escHtml(notes)}</aside>` : ''
return (
` <div class="slide-frame"><section class="slide" data-kind="imported">\n` +
` <div class="eyebrow">Slide ${i + 1}</div>\n` +
parts.join('\n') + notesHtml +
`\n </section></div>`
)
}).join('\n')
}
const STYLE = `<style>
@page{size:1280px 720px;margin:0}
*{box-sizing:border-box}
html{--ink:#191917;--dim:#6b6a63;--accent:#c2452f;--line:#e5e3dc;--bg:#faf9f6}
body{margin:0;background:#e8e6df;font-family:ui-sans-serif,system-ui,-apple-system,Arial,sans-serif;color:var(--ink)}
.deck{display:flex;flex-direction:column;align-items:center;gap:20px;padding:24px}
.slide-frame{width:1280px;flex-shrink:0}
.slide{width:1280px;height:720px;background:var(--bg);overflow:hidden;position:relative;
padding:54px 72px;box-shadow:0 12px 40px rgba(0,0,0,.15);display:flex;flex-direction:column;gap:16px}
.eyebrow{font:700 13px/1 ui-monospace,monospace;letter-spacing:.22em;text-transform:uppercase;color:var(--accent)}
h2.title{font:400 44px/1.08 Georgia,'Times New Roman',serif;margin:4px 0 8px;max-width:24ch;letter-spacing:-.01em}
p{font-size:22px;line-height:1.5;color:#2c2b27;margin:0;max-width:70ch}
ul{margin:0;padding-left:26px} li{font-size:20px;line-height:1.55;margin:4px 0}
table{border-collapse:collapse;font-size:16px} th,td{border:1px solid var(--line);padding:7px 12px;text-align:left}
th{background:#f0eee7;font-weight:700}
img{border:1px solid var(--line);border-radius:4px}
figure{margin:0} figure.chart{display:block} figure.vector figcaption{font-size:15px;color:var(--dim);font-style:italic}
figure[data-unpaper-image]{font:14px/1.4 ui-monospace,monospace;color:var(--dim);border:1px dashed var(--line);border-radius:4px;padding:10px 14px}
.notes{display:none} /* speaker notes: hidden on the slide, become PPTX notes via Tool 2 */
@media print{body{background:#fff}.deck{gap:0;padding:0}.slide{box-shadow:none}}
</style>`
function doc({ title, slides, provenance, mode, imageIndex, banner }) {
const prov = JSON.stringify(provenance).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/'/g, '&#39;')
return `<!doctype html>
<html lang="en" data-theme="paper" data-bg="plain" data-accent="default" data-unpaper-provenance='${prov}'>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escHtml(title)} — imported to unPaper</title>
${STYLE}
</head>
<body>
${banner}
<div class="deck">
${renderSlides(slides, mode, imageIndex)}
</div>
</body>
</html>
`
}
export function buildUnpaperDoc({ title, slides, provenance, imageIndex }) {
return doc({
title, slides, provenance, mode: 'unpaper', imageIndex,
banner: `<!-- imported from pptx: self-contained archive copy (images inline). The
companion deck.ai.html is the kilobytes-sized copy to paste into your AI;
rehydrate its redesign with deck.images.json to splice images back. -->`,
})
}
export function buildAiDoc({ title, slides, provenance }) {
return doc({
title, slides, provenance, mode: 'ai', imageIndex: new Map(),
banner: `<!-- imported from pptx: AI-facing copy. Images are figure
placeholders carrying a data-unpaper-image id — KEEP each one where the image
belongs and NEVER invent a src. Paste this + the .rebuild-prompt.txt into your
AI, then run: rehydrate redesign.html deck.images.json -o final.html -->`,
})
}
export function buildRebuildPrompt(title, count, { charts, images }) {
return `I imported an old PowerPoint ("${title}", ${count} slides) into rough unPaper HTML.
It is a plain content dump — one <section class="slide"> per source slide, with the
largest text as the title and everything else stacked below. The layout is throwaway;
the words, tables, charts and speaker notes are what matter.
Please REDESIGN it into a proper, print-ready unPaper deck:
- Keep every slide's real content (titles, body text, tables, the ${charts} chart
figure${charts === 1 ? '' : 's'}, and the <aside class="notes">).
- IMAGES: this deck carries ${images} image placeholder${images === 1 ? '' : 's'} as
<figure data-unpaper-image="img_N" ...>. KEEP each placeholder element exactly where
its image belongs in your redesign. NEVER invent a src or an <img> — the real bytes
are spliced back afterward by "rehydrate" using the id. You may move a placeholder to
fit the layout; you may not delete or duplicate one.
- CHARTS: keep each <figure data-unpaper-chart='...'> — the JSON already holds the real
series data, so it converts back to an editable PowerPoint chart. Restyle freely; do
not change the numbers.
- Give each slide the layout it deserves — pick the right element per idea (statement,
two-column split, comparison table, KPI stats, chart, matrix, process flow, quote,
cards) instead of a flat stack. Split any overloaded slide into two.
- Rewrite weak titles as full-sentence takeaways; tighten body copy to unPaper's budgets
(~8-word titles, ~40-60 words of body, up to ~5 bullets, 2-3 cards).
- Return ONE self-contained .html file that follows the unPaper format rules (fixed
1280x720 slides, self-contained <style>/@page/<script>, renders without JavaScript).
Use the current unPaper template and format rules as your target (see unPaper's "Copy the
prompt" output, or the /create page). The imported HTML follows.`
}
/**
* verbatim.mjs — the Grade A "verbatim" emitter (E8.7, BACKWARD-COMPILER §3).
*
* Cascade-resolver facts (resolve.mjs) → one absolutely-positioned,
* SELF-CONTAINED deck.verbatim.html: 1280×720 sections, resolved styles,
* real text, real tables, images inlined as data URIs straight from the zip.
* Marked `data-unpaper-verbatim="import"` and honestly L0/foreign — it
* converts back to .pptx through Tool 2's foreign-deck path (selector
* `.slide`), and it archives what the source deck actually said even when
* the AI-redesign path is never used.
*
* Charts and SmartArt render as honest data boxes (type + values / node
* tree) — Grade A preserves evidence, it does not fake the drawing. The
* forward engine's font aliasing is applied here (spec §4.4: "for Grade A").
*/
import { loadDeck, resolveSlide, primeIslandCaches } from './resolve.mjs'
import { escHtml, escAttr, mimeOf } from './shared.mjs'
const ALIGN = { l: 'left', ctr: 'center', r: 'right', just: 'justify' }
// The forward engine's universal-equivalent aliasing, in miniature: map the
// most common Office faces to metrically-safe universal ones so the verbatim
// file renders without font dialogs anywhere.
const FONT_ALIASES = {
calibri: 'Carlito, Calibri, Arial, sans-serif',
'calibri light': 'Carlito, "Calibri Light", Arial, sans-serif',
cambria: 'Caladea, Cambria, Georgia, serif',
arial: 'Arial, Helvetica, sans-serif',
helvetica: 'Helvetica, Arial, sans-serif',
'times new roman': '"Times New Roman", Times, serif',
georgia: 'Georgia, serif',
verdana: 'Verdana, Geneva, sans-serif',
'segoe ui': '"Segoe UI", -apple-system, Arial, sans-serif',
}
const fontStack = (name) => FONT_ALIASES[(name ?? '').toLowerCase()] ?? `${JSON.stringify(name ?? 'Arial').replace(/"/g, "'")}, Arial, sans-serif`
const pos = (b) => `left:${b[0]}px;top:${b[1]}px;width:${b[2]}px;height:${b[3]}px`
const rotCss = (el) => (el.rot || el.flip?.[0] || el.flip?.[1]
? `;transform:${el.rot ? `rotate(${el.rot}deg)` : ''}${el.flip?.[0] ? ' scaleX(-1)' : ''}${el.flip?.[1] ? ' scaleY(-1)' : ''}`
: '')
function textDiv(el) {
const s = el.style
const css =
`${pos(el.bbox)};font:${s.bold ? '700' : '400'} ${s.size ?? 18}pt/${1.25} ${fontStack(s.font?.mapped)};` +
`color:${s.color ?? '#000'};text-align:${ALIGN[s.align] ?? 'left'}` +
(s.fill ? `;background:${s.fill}` : '') +
(s.stroke ? `;border:1px solid ${s.stroke}` : '') +
(s.opacity !== undefined && s.opacity !== 1 ? `;opacity:${s.opacity}` : '') +
rotCss(el)
const bulletPrefix = s.bullet?.kind === 'char' ? escHtml(s.bullet.char) + ' ' : ''
const lines = escHtml(el.content ?? '').split('\n').map((l) => (bulletPrefix && l ? bulletPrefix + l : l))
// text overflows VISIBLY: with substituted fonts the measured bbox can run
// short, and losing words would betray the whole point of Grade A
return `<div class="el" style="${css};overflow:visible">${lines.join('<br>')}</div>`
}
function shapeDiv(el) {
const s = el.style
const css =
`${pos(el.bbox)}` +
(s.fill ? `;background:${s.fill}` : '') +
(s.stroke ? `;border:1px solid ${s.stroke}` : ';border:0') +
(s.opacity !== undefined && s.opacity !== 1 ? `;opacity:${s.opacity}` : '') +
rotCss(el)
return `<div class="el" style="${css}"></div>`
}
function tableEl(el) {
if (!el.data?.rows) return shapeDiv(el)
const rows = el.data.rows
.map(
(r) =>
`<tr>${r.cells
.map((c) => `<td${c.colspan ? ` colspan="${c.colspan}"` : ''}${c.rowspan ? ` rowspan="${c.rowspan}"` : ''}>${escHtml(c.text)}</td>`)
.join('')}</tr>`,
)
.join('')
return `<table class="el vtbl" style="${pos(el.bbox)}"><tbody>${rows}</tbody></table>`
}
function chartEl(el) {
const d = el.data
if (!d) return `<div class="el vdata" style="${pos(el.bbox)}">[chart — data unavailable]</div>`
const series = (d.series ?? [])
.map((s) => `<div><b>${escHtml(s.name ?? '')}</b> ${(s.values ?? []).map((v) => escHtml(String(v))).join(' · ')}</div>`)
.join('')
return (
`<div class="el vdata" style="${pos(el.bbox)}"><div class="vlab">${escHtml(d.type ?? 'chart')} chart</div>` +
`<div>${(d.labels ?? []).map((l) => escHtml(String(l))).join(' · ')}</div>${series}</div>`
)
}
function diagramEl(el) {
const d = el.data
if (!d?.nodes?.length) return `<div class="el vdata" style="${pos(el.bbox)}">[diagram]</div>`
const node = (n) => `<li>${escHtml(n.text)}${n.children.length ? `<ul>${n.children.map(node).join('')}</ul>` : ''}</li>`
return `<div class="el vdata" style="${pos(el.bbox)}"><div class="vlab">${escHtml(d.kind)} diagram</div><ul>${d.nodes.map(node).join('')}</ul></div>`
}
/** zip + options → the self-contained verbatim HTML document. */
export async function emitVerbatim(zip, { title = 'Imported deck', source = 'deck.pptx' } = {}) {
const model = await primeIslandCaches(await loadDeck(zip))
// media cache: imageRef (zip path) → data URI, loaded once per distinct path
const mediaUri = new Map()
const uriFor = async (target) => {
if (!target || !zip.file(target)) return null
if (!mediaUri.has(target)) {
const b64 = await zip.file(target).async('base64')
mediaUri.set(target, `data:${mimeOf(target)};base64,${b64}`)
}
return mediaUri.get(target)
}
const sections = []
for (let i = 0; i < model.slides.length; i++) {
const facts = resolveSlide(model, i)
const parts = []
for (const el of facts.elements) {
if (el.kind === 'image') {
const uri = await uriFor(el.imageRef)
parts.push(
uri
? `<img class="el" style="${pos(el.bbox)};object-fit:contain${rotCss(el)}" alt="" src="${escAttr(uri)}">`
: `<div class="el vdata" style="${pos(el.bbox)}">[image missing]</div>`,
)
} else if (el.kind === 'table') parts.push(tableEl(el))
else if (el.kind === 'chart') parts.push(chartEl(el))
else if (el.kind === 'diagram') parts.push(diagramEl(el))
else if (el.kind === 'text' || el.kind === 'chrome' || el.kind === 'rescued') parts.push(textDiv(el))
else parts.push(shapeDiv(el))
}
const notes = facts.notes ? `<aside class="notes">${escHtml(facts.notes)}</aside>` : ''
sections.push(`<section class="slide">${parts.join('\n')}${notes}</section>`)
}
return `<!doctype html>
<html lang="en" data-unpaper-verbatim="import" data-unpaper-provenance='${escAttr(JSON.stringify({ generator: 'pptx2unpaper-verbatim', source, grade: 'A' }))}'>
<head>
<meta charset="UTF-8">
<title>${escHtml(title)} — verbatim import</title>
<style>
@page{size:1280px 720px;margin:0}
*{box-sizing:border-box;margin:0;padding:0}
body{background:#666}
.slide{position:relative;width:1280px;height:720px;overflow:hidden;background:#fff;margin:16px auto}
@media print{body{background:#fff}.slide{margin:0;page-break-after:always}}
.el{position:absolute;white-space:pre-wrap;overflow:hidden}
.vtbl{border-collapse:collapse;font:400 12pt Arial,sans-serif}
.vtbl td{border:1px solid #999;padding:4px 8px;vertical-align:top}
.vdata{border:1px dashed #999;padding:8px;font:400 11pt Arial,sans-serif;color:#333}
.vdata .vlab{font-weight:700;text-transform:uppercase;font-size:9pt;letter-spacing:.08em;color:#777}
.vdata ul{margin-left:18px}
.notes{display:none}
</style>
</head>
<body>
${sections.join('\n')}
</body>
</html>
`
}
export const VERSION = "0.2.0"
# @unpaper/cli — License
Copyright © 2026 reHeritage GmbH, Berlin (HRB 254687 B). All rights reserved.
## 1. What you may do, free of charge
reHeritage GmbH grants you a non-exclusive, non-transferable, revocable
licence to install and run this package, unmodified, on machines you or your
organisation control, for any lawful purpose — **personal and commercial use
included**. No account, no fee, no telemetry.
## 2. Your documents are yours
Everything this tool produces from your content — converted .pptx files,
imported or generated HTML, templates, brand kits — is yours. You may use,
modify, distribute and sell those output files without restriction or
attribution. To the extent any template fragments ship inside your outputs,
reHeritage GmbH licenses them to you as part of your documents.
## 3. The format is open
The unPaper HTML format specification is licensed separately under Creative
Commons Attribution 4.0. Anyone may build software that reads or writes
unPaper HTML — including commercial and competing software — without
permission or fee. This licence restricts this package's code, never the
format.
## 4. What requires a commercial licence
The software itself is proprietary. Without a separate written licence from
reHeritage GmbH you may not:
- redistribute, resell, sublicense or re-publish this package or substantial
parts of its code (installing it through npm's official channel is use,
not redistribution);
- embed it in, or bundle it with, a product or service offered to third
parties, or expose its functionality to third parties as a hosted or
managed service;
- remove or alter copyright and licence notices.
Running the package internally — by you, your team, your CI, or AI agents
acting for your organisation — is ordinary use under §1 and needs no
commercial licence.
## 5. No warranty
The package is provided "as is", without warranty of any kind. To the
maximum extent permitted by law, reHeritage GmbH is not liable for any
damages arising from its use. Where mandatory law (including German law on
intent and gross negligence) provides otherwise, that law prevails.
## 6. Contact
Commercial licences and questions: reHeritage GmbH · Berlin ·
https://www.unpaper.ai/impressum
// Browser discovery (E2.4 step 3): CHROME_PATH / UNPAPER_BROWSER env →
// --browser flag → known per-OS locations → actionable error naming all three.
import { existsSync } from 'node:fs'
const CANDIDATES = [
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/bin/microsoft-edge',
// Windows
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
]
export function findBrowser(explicit) {
const chosen = explicit || process.env.UNPAPER_BROWSER || process.env.CHROME_PATH
if (chosen) {
if (existsSync(chosen)) return chosen
throw new Error(`Browser not found at "${chosen}" — check the path.`)
}
const found = CANDIDATES.find((p) => existsSync(p))
if (found) return found
throw new Error(
'No local Chrome/Chromium/Edge found. Install Google Chrome, or pass the browser path via the UNPAPER_BROWSER env var or the --browser flag. Nothing was converted.',
)
}
/**
* check (E2.4) — validate a deck without a browser: conformance level L0–L3,
* health checks, lint issues, and a ready-to-paste fix request. Exit-code
* contract (load-bearing for CI/GitHub Actions): level ≥ L1 and no failed
* health checks → 0, else 1.
*
* Template mode (T6): `template: true` validates a COMPANY TEMPLATE from
* `templatize` against the byte-law instead — per-rule checks, mechanical
* re-lock of a drifted sizing script, and the repaired html to save. Same
* exit contract: ok → 0.
*/
import { readFileSync, existsSync } from 'node:fs'
import { conformanceLevel, healthCheck, lintDeck, buildFixRequest, listSlides, templateCheck, detectTemplateBase, buildTemplateFixRequest } from '../dist/core.mjs'
import { starter } from './library.mjs'
/** @returns {{ level, slides, issues, toNext, fixRequest, ok }} */
export function check({ html, path: filePath, template, family }) {
if (!html && !filePath) throw new Error("Provide either 'html' (string) or 'path' (file path) — exactly one.")
if (html && filePath) throw new Error("Provide either 'html' or 'path', not both.")
const source = html ?? readFileWithError(filePath)
if (template) {
const detected = detectTemplateBase(source)
const fam = family ?? detected?.baseFamily
if (!fam)
throw new Error(
'Cannot detect which base family this template was made from. Pass family: standard|consulting|design|editorial (the base it re-skins).',
)
const res = templateCheck(source, starter(fam))
const ok = res.checks.every((c) => c.ok)
const small = res.html.length <= 1_000_000
return {
template: true,
baseFamily: fam,
detectedBase: Boolean(detected) && !family,
scriptRepaired: res.scriptRepaired,
checks: res.checks,
ok,
fixRequest: ok ? '' : buildTemplateFixRequest(res.checks),
repairedHtml: small ? res.html : null,
...(small ? {} : { note: 'html too large to inline — run the CLI: unpaper check <file> --template -o repaired.html' }),
}
}
const conf = conformanceLevel(source)
const checks = healthCheck(source)
const lints = lintDeck(source)
const slides = listSlides(source)
const issues = [
...checks.filter((c) => !c.ok).map((c) => ({ severity: 'error', message: c.label, fix: '' })),
...lints.map((l) => ({ severity: l.level === 'error' ? 'error' : 'warn', message: l.label, fix: l.detail ?? '' })),
]
const level = conf ? `L${conf.level}` : null
const ok = conf !== null && conf.level >= 1 && !issues.some((i) => i.severity === 'error')
return {
level,
slides: slides.length,
issues,
toNext: conf?.toNext ?? [],
fixRequest: issues.length ? buildFixRequest(source) : '',
ok,
}
}
function readFileWithError(p) {
if (!existsSync(p)) throw new Error(`Cannot read ${p}. Check the path is absolute and the file exists.`)
return readFileSync(p, 'utf8')
}
export function formatCheck(res) {
if (res.template) {
const failing = res.checks.filter((c) => !c.ok).length
const lines = [
`company template: ${res.baseFamily} base${res.detectedBase ? ' (detected)' : ''} · ${res.checks.length} checks · ${res.ok ? 'ok' : `${failing} failing`}`,
]
if (res.scriptRepaired) lines.push(' script re-locked mechanically to the base')
for (const c of res.checks) lines.push(` ${c.ok ? '✓' : '✗'} ${c.label}`)
if (!res.ok) lines.push('fix request ready — send res.fixRequest (or --json .fixRequest) back to the authoring AI')
return lines.join('\n')
}
const lines = [
`conformance: ${res.level ?? 'not HTML'} · ${res.slides} slides · ${res.issues.length} issue${res.issues.length === 1 ? '' : 's'}`,
]
for (const i of res.issues.slice(0, 40)) lines.push(` [${i.severity}] ${i.message}${i.fix ? ` — ${i.fix}` : ''}`)
if (res.toNext.length) lines.push(`to reach the next level: ${res.toNext.join(' · ')}`)
return lines.join('\n')
}
/**
* convert (E2.4) — the forward engine as a callable: self-contained HTML deck
* in, real editable .pptx out. Local, offline, no AI. Faithful port of the
* repo's scripts/html2pptx/cli.mjs pipeline (measure → per-slide extract +
* capture → buildPptx → patchPptx), minus the experimental font-embedding.
*/
import { existsSync, writeFileSync, mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import puppeteer from 'puppeteer-core'
import { detectSlides, h2pInit } from '../dist/engine/extract.mjs'
import { buildPptx } from '../dist/engine/build.mjs'
import { patchPptx } from '../dist/engine/patch.mjs'
import { findBrowser } from './browser.mjs'
/**
* @param {object} opts
* html?: string — the deck as text (agents in sandboxes)
* path?: string — or a file path; exactly one of html/path
* out?: string — output .pptx path (default: alongside input, or CWD/deck.pptx)
* selector?: string (default ".slide"; auto-detection falls back for foreign decks)
* noShapes?: boolean — flatten everything into the background image
* browserPath?: string
* log?: (line: string) => void (default stderr — stdout stays clean for --json/MCP)
* @returns {Promise<{pptxPath, slides, report, theme, flatCaptures, durationMs}>}
*/
export async function convert(opts = {}) {
const t0 = Date.now()
const log = opts.log ?? ((line) => process.stderr.write(line + '\n'))
if (!opts.html && !opts.path) throw new Error("Provide either 'html' (string) or 'path' (file path) — exactly one.")
if (opts.html && opts.path) throw new Error("Provide either 'html' or 'path', not both.")
let inputPath = opts.path ? path.resolve(opts.path) : null
if (inputPath && !existsSync(inputPath)) throw new Error(`Cannot read ${inputPath}. Check the path is absolute and the file exists.`)
if (opts.html) {
inputPath = path.join(mkdtempSync(path.join(tmpdir(), 'unpaper-cli-')), 'deck.html')
writeFileSync(inputPath, opts.html)
}
const out = path.resolve(
opts.out ?? (opts.path ? inputPath.replace(/\.x?html?$/i, '') + '.pptx' : path.join(process.cwd(), 'deck.pptx')),
)
const executablePath = findBrowser(opts.browserPath)
const selector = opts.selector ?? '.slide'
const browser = await puppeteer.launch({ executablePath, headless: true })
try {
const page = await browser.newPage()
await page.setViewport({ width: 1600, height: 900, deviceScaleFactor: 2 })
await page.goto(pathToFileURL(inputPath).href, { waitUntil: 'networkidle0', timeout: 60000 })
await page.evaluate(() => document.fonts.ready)
const det = await page.evaluate(`(${detectSlides})(document, ${JSON.stringify(selector)})`)
if (!det.count)
throw new Error(
`No slide sections found (looked for ${JSON.stringify(selector)} and auto-detectable slide containers: ${det.tried.join(', ')}). If this is a deck, ensure fixed-size slide elements; run 'check' for details.`,
)
if (det.detected) log(`selector: "${selector}" matched nothing — auto-detected "${det.selector}" (${det.count} slides)`)
await page.evaluate(
`window.__h2p = (${h2pInit})(document, ${JSON.stringify({ slideSelector: det.selector, shapes: !opts.noShapes, autofit: false, wrapText: false })})`,
)
const meta = await page.evaluate(() => window.__h2p.measure())
if (!meta.count) throw new Error(`No slides matched "${det.selector}".`)
log(`deck: ${meta.count} slides · ${meta.wPx}×${meta.hPx}px · "${meta.title}"`)
if (meta.mixedSizes) log(' ⚠ slides have mixed sizes — deck size uses the most common one')
await page.evaluate(() => window.__h2p.hideFixedOverlays())
const els = await page.$$('[data-h2p-sl]')
const slides = []
const svgFallbacks = {}
const flatCaptures = []
for (let i = 0; i < meta.count; i++) {
await page.evaluate((n) => window.__h2p.showOnly(n), i)
const data = await page.evaluate((n) => window.__h2p.extract(n), i)
await page.evaluate(() => window.__h2p.bgOn())
const buf = await els[i].screenshot({ type: 'jpeg', quality: 92 })
if (data.flatImgRects?.length) {
const byReason = {}
for (const r of data.flatImgRects) byReason[r.reason || 'complex-bg'] = (byReason[r.reason || 'complex-bg'] || 0) + 1
for (const [reason, count] of Object.entries(byReason)) flatCaptures.push({ slide: i + 1, reason, count })
const flatEls = await els[i].$$('[data-h2p-flatimg]')
for (let j = 0; j < data.flatImgRects.length; j++) {
const flatEl = flatEls[j]
if (!flatEl) continue
try {
const pngBuf = await flatEl.screenshot({ type: 'png', omitBackground: true })
data.imgs.push({ ...data.flatImgRects[j], data: 'data:image/png;base64,' + Buffer.from(pngBuf).toString('base64') })
} catch { /* stays in the background capture */ }
}
}
await page.evaluate(() => window.__h2p.bgOff())
if (data.svgs?.length) {
svgFallbacks[i + 1] = await page.evaluate(async (icons, scale) => {
const outPngs = []
for (const ic of icons) {
const img = new Image()
await new Promise((res, rej) => {
img.onload = res
img.onerror = rej
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(ic.svg)
})
const c = document.createElement('canvas')
c.width = Math.max(1, Math.round(ic.w * scale))
c.height = Math.max(1, Math.round(ic.h * scale))
c.getContext('2d').drawImage(img, 0, 0, c.width, c.height)
outPngs.push(c.toDataURL('image/png'))
}
return outPngs
}, data.svgs, 3)
}
slides.push({
bg: 'data:image/jpeg;base64,' + Buffer.from(buf).toString('base64'),
boxes: data.boxes,
shapes: data.shapes,
svgs: data.svgs,
imgs: data.imgs,
freeforms: data.freeforms,
lists: data.lists,
tables: data.tables,
charts: data.charts,
notes: data.notes,
bgPlain: data.bgPlain,
bgColor: data.bgColor,
})
log(` slide ${i + 1}/${meta.count}: ${data.boxes.length} text · ${(data.shapes || []).length} shapes · ${(data.svgs || []).length} svg · ${(data.imgs || []).length} img`)
}
const fontStacks = await page.evaluate(() => window.__h2p.fontStacks())
const { pptx, gradients, bulletColors, customGeom, report, theme } = buildPptx({
wPx: meta.wPx,
hPx: meta.hPx,
title: meta.title,
fontMap: {},
fontStacks,
keepFonts: [],
slides,
})
let outData = await pptx.write({ outputType: 'nodebuffer' })
if (theme || meta.provenance || Object.keys(gradients).length || Object.keys(bulletColors).length || Object.keys(customGeom).length || Object.keys(svgFallbacks).length) {
outData = await patchPptx(outData, { gradients, bulletColors, customGeom, theme, svgFallbacks, fonts: [], provenance: meta.provenance }, 'nodebuffer')
}
writeFileSync(out, outData)
return { pptxPath: out, slides: meta.count, report, theme, flatCaptures, durationMs: Date.now() - t0 }
} finally {
await browser.close()
}
}
export function formatReport(result) {
const lines = []
const totalNative = result.report.reduce((n, r) => n + r.native, 0)
lines.push(`conversion report — ${totalNative} native objects across ${result.report.length} slides:`)
for (const r of result.report) {
const parts = []
if (r.textBoxes) parts.push(`${r.textBoxes} text`)
if (r.lists) parts.push(`${r.lists} list`)
if (r.tables) parts.push(`${r.tables} table`)
if (r.charts) parts.push(`${r.charts} chart`)
if (r.shapes) parts.push(`${r.shapes} shape`)
if (r.freeforms) parts.push(`${r.freeforms} freeform`)
if (r.vectorPics) parts.push(`${r.vectorPics} vector`)
if (r.images) parts.push(`${r.images} image`)
lines.push(` slide ${r.slide}: ${parts.join(' · ') || '(capture only)'} · bg:${r.background}${r.notes ? ' · notes' : ''}`)
}
for (const { slide, reason, count } of result.flatCaptures) lines.push(` slide ${slide}: ${count} ${reason} captured as image`)
return lines.join('\n')
}
/**
* starter + patterns (E2.4) — the templates and the pattern registry, from the
* same committed sources as the product (copied verbatim at build time).
*/
import { readFileSync } from 'node:fs'
const here = new URL('.', import.meta.url)
const read = (rel) => readFileSync(new URL(rel, here), 'utf8')
export const FAMILIES = ['standard', 'consulting', 'design', 'editorial']
export function starter(family = 'standard') {
if (!FAMILIES.includes(family)) throw new Error(`Unknown family "${family}" — one of: ${FAMILIES.join(', ')}.`)
const file = family === 'standard' ? 'slides.html' : `slides-${family}.html`
return read(`../templates/${file}`)
}
// A 10-line rules digest for agents — generated wording, aligned with llms.txt.
export function rulesDigest() {
return [
'unPaper HTML rules (digest):',
'1. One self-contained file: inline <style>, no external CSS/JS/fonts/images, no network.',
'2. Keep the template’s <style>, @page and <script> blocks VERBATIM; write content into the structure.',
'3. One <section class="slide"> per slide, fixed 1280×720; the deck must render complete with JavaScript disabled.',
'4. Use real semantics: <table> for tables, <ul>/<ol> for lists, <a> for links — they convert to native objects.',
'5. Give every chart inline SVG for print AND data-unpaper-chart JSON (type/categories/series) — it becomes a data-editable chart.',
'6. Put speaker notes in <aside class="notes"> per slide.',
'7. One idea per slide; split when full; never pad.',
'8. Keep the <html> data-* look attributes (data-theme/bg/accent…) — the template CSS reads them.',
'9. Validate with `check`, then convert with `convert` — a clean check makes the .pptx fully editable.',
].join('\n')
}
export function patterns({ family, id } = {}) {
const catalog = JSON.parse(read('../dist/data/catalog.json'))
const compact = JSON.parse(read('../dist/data/compact-patterns.json'))
if (id) {
const p = catalog.patterns.find((x) => x.id === id.toUpperCase())
if (!p) throw new Error(`No pattern "${id}". List them with no arguments.`)
return [
`${p.id} · ${p.name} (${p.familyLabel})`,
`job: ${p.job}`,
`converter: ${p.support}${p.ready ? ' (in prompt packs)' : ''}`,
p.compact ? `prompt fragment: ${p.compact}` : 'no prompt fragment (family grammar travels whole)',
p.useWhen.length ? `use when: ${p.useWhen.join(' · ')}` : '',
p.primitives.length ? `primitives: ${p.primitives.join(', ')}` : '',
].filter(Boolean).join('\n')
}
const fams = family ? catalog.patterns.filter((p) => p.family === family) : catalog.patterns
if (family && !fams.length) throw new Error(`Unknown family "${family}" — one of: ${[...new Set(catalog.patterns.map((p) => p.family))].join(', ')}.`)
const lines = [
`unPaper pattern library — ${fams.length} patterns${family ? ` (${family})` : ' across 4 families'}. Ask for one by id for its prompt fragment.`,
]
for (const p of fams) lines.push(` ${p.id} ${p.name} — ${p.job} [${p.support}]`)
if (!family) lines.push(`consulting packs (Goal presets): ${compact.subtypes.map((s) => s.id).join(' · ')}`)
lines.push('full machine catalog: https://www.unpaper.ai/patterns.json')
return lines.join('\n')
}
/**
* mcp (E2.5) — the local MCP server: `npx @unpaper/cli mcp`, stdio.
*
* Tool names, descriptions, schemas and error texts follow
* scripts/agents/MCP-DESIGN.md VERBATIM — descriptions are prompts, and
* changing them is a reviewed product change. ALL logging goes to stderr
* (stdout is the protocol; one stray console.log corrupts the session).
* Every tool returns text (paths + JSON), never binary.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { z } from 'zod'
import path from 'node:path'
import { writeFileSync, readFileSync } from 'node:fs'
import { VERSION } from '../dist/version.mjs'
const text = (s) => ({ content: [{ type: 'text', text: s }] })
const errText = (s) => ({ content: [{ type: 'text', text: s }], isError: true })
const log = (s) => process.stderr.write(`[unpaper-mcp] ${s}\n`)
export async function serve() {
const server = new McpServer({ name: 'unpaper-cli', version: `${VERSION}, contract v2` })
server.tool(
'starter',
'Get a ready-to-fill unPaper HTML slide template. Use this FIRST when asked to create a presentation/deck/slides as .pptx or print-ready HTML: write your content INTO the returned file\'s structure (keep its <style>, @page and <script> blocks verbatim; one <section class="slide"> per slide; add data-unpaper-chart JSON to every chart; put speaker notes in <aside class="notes">). Then validate with `check` and convert with `convert`.',
{ family: z.enum(['standard', 'editorial', 'design', 'consulting']).optional().describe('template family (default standard)') },
async ({ family }) => {
const { starter, rulesDigest } = await import('./library.mjs')
return text(rulesDigest() + '\n\n' + starter(family ?? 'standard'))
},
)
server.tool(
'check',
'Validate unPaper HTML (or any HTML deck) BEFORE converting. Returns the conformance level (L0–L3), per-slide issues, and a ready fix-request text. Call after writing or editing a deck; if level < L1 or errors exist, apply the fixes and call again. A clean check makes `convert` output fully editable. Set template: true (with optional family) to validate a COMPANY TEMPLATE from `templatize` against the byte-law instead: per-rule checks, mechanical re-lock of a drifted sizing script, a fix request on failure, and the repaired html to save.',
{
html: z.string().optional().describe('the deck as an HTML string'),
path: z.string().optional().describe('or an absolute file path — exactly one of html/path'),
template: z.boolean().optional().describe('validate as a company template (byte-law) instead of a deck'),
family: z.enum(['standard', 'editorial', 'design', 'consulting']).optional().describe('template mode only: the base family (default: auto-detected)'),
},
async ({ html, path: p, template, family }) => {
try {
const { check } = await import('./check.mjs')
return text(JSON.stringify(check({ html, path: p, template, family }), null, 2))
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
server.tool(
'convert',
'Convert an unPaper/self-contained HTML deck to a REAL, editable PowerPoint (.pptx): native text boxes, tables, data-editable charts, movable shapes, speaker notes. Runs fully locally — no network, no upload. Prefer running `check` first; conversion succeeds on most HTML but editability follows conformance. Returns the output file path and a per-slide report.',
{
html: z.string().optional().describe('the deck as an HTML string'),
path: z.string().optional().describe('or an absolute file path — exactly one of html/path'),
out: z.string().optional().describe('output .pptx path (default: alongside the input, or CWD/deck.pptx)'),
noShapes: z.boolean().optional().describe('flatten everything into the background image'),
},
async ({ html, path: p, out, noShapes }) => {
try {
const { convert } = await import('./convert.mjs')
const res = await convert({ html, path: p, out, noShapes, log })
return text(
JSON.stringify(
{
pptxPath: res.pptxPath,
slides: res.slides,
report: res.report.map((r) => ({ slide: r.slide, nativeObjects: r.native, background: r.background })),
durationMs: res.durationMs,
},
null,
2,
),
)
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
server.tool(
'patterns',
'Browse the unPaper pattern library (consulting slide layouts: exec summary, comparison, waterfall, 2×2, roadmap…). Use when deciding HOW to lay out content before writing the deck. Returns compact pattern descriptions with structure hints; write the deck yourself using them — there is no generation here.',
{
family: z.string().optional().describe('standard | consulting | design | editorial'),
id: z.string().optional().describe('one pattern id, e.g. C16'),
},
async ({ family, id }) => {
try {
const { patterns } = await import('./library.mjs')
return text(patterns({ family, id }))
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
server.tool(
'import',
"Rescue an existing .pptx (or legacy .ppt with LibreOffice installed) into AI-readable unPaper HTML: content, tables, notes, chart DATA, image placeholders + a facts file. Use when asked to redesign, update, or 'un-paper' an existing PowerPoint. Then rewrite the returned deck.ai.html into a proper deck (keep every <figure data-unpaper-image> placeholder), call `rehydrate`, then `check` + `convert`.",
{
path: z.string().describe('absolute path to the .pptx/.ppt/.pps/.odp'),
outDir: z.string().optional().describe('directory for outputs (default: alongside the input)'),
emitBrandKit: z.boolean().optional(),
},
async ({ path: p, outDir, emitBrandKit }) => {
try {
const { maybeElevate } = await import('../dist/importer/elevate.mjs')
let lifted
try {
lifted = maybeElevate(p, {})
} catch (e) {
return errText(
"This is a legacy PowerPoint file (.ppt). Converting it requires LibreOffice ('soffice') installed locally — install it, or re-save the file as .pptx in any Office app, then retry.\n" + String(e.message ?? e),
)
}
const { runImport } = await import('../dist/importer/import.mjs')
const base = path.join(outDir ?? path.dirname(p), path.basename(p).replace(/\.[^.]+$/, ''))
const res = await runImport({ in: lifted.path, out: base + '.unpaper.html', emitBrandKit })
const { readFile } = await import('node:fs/promises')
const { createRequire } = await import('node:module')
const JSZip = createRequire(import.meta.url)('jszip')
const { emitVerbatim } = await import('../dist/importer/verbatim.mjs')
const zip = await JSZip.loadAsync(await readFile(lifted.path))
const vOut = base + '.verbatim.html'
writeFileSync(vOut, await emitVerbatim(zip, { title: path.basename(p), source: path.basename(p) }))
// the deposition (facts + contact sheets) needs a browser — best-effort
let factsJson = null
let contactSheets = []
try {
const { findBrowser } = await import('./browser.mjs')
const { emitDeposition } = await import('../dist/importer/deposition.mjs')
const depo = await emitDeposition(zip, { base, chrome: findBrowser(), log })
factsJson = depo.factsPath
contactSheets = depo.contactSheets
} catch (e) {
log(`deposition skipped: ${e.message}`)
}
return text(
JSON.stringify(
{ aiHtml: res.outputs.ai, verbatimHtml: vOut, imagesJson: res.outputs.images, factsJson, contactSheets, rebuildPrompt: res.outputs.prompt, brandKit: res.outputs.brand ?? null, stats: res.stats },
null,
2,
),
)
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
server.tool(
'rehydrate',
'Merge real images back into a redesigned deck. Call after rewriting an imported deck.ai.html: replaces every <figure data-unpaper-image> placeholder with the stored image from deck.images.json. Fails loudly if placeholders were lost — restore them and retry.',
{
html: z.string().optional().describe('the redesigned deck as an HTML string'),
path: z.string().optional().describe('or an absolute file path — exactly one of html/path'),
imagesJson: z.string().describe('absolute path to deck.images.json'),
out: z.string().optional().describe('output path (default: alongside the input)'),
},
async ({ html, path: p, imagesJson, out }) => {
try {
if (!html && !p) return errText("Provide either 'html' (string) or 'path' (file path) — exactly one.")
const { runRehydrate } = await import('../dist/importer/rehydrate.mjs')
const target = out ?? (p ? p.replace(/\.html$/i, '') + '.final.html' : path.join(process.cwd(), 'deck.final.html'))
const res = await runRehydrate({ html: html ?? p, registry: readFileSync(imagesJson, 'utf8'), out: target })
if (res.unmatchedFigures.length)
return errText(
`The redesign lost ${res.unmatchedFigures.length} image placeholder(s): ${res.unmatchedFigures.join(', ')}. Re-insert <figure data-unpaper-image="{id}"> where each image belongs and call rehydrate again.`,
)
return text(JSON.stringify({ htmlPath: res.out, restored: res.placed, missingIds: res.unmatchedFigures, orphanedIds: res.unusedRegistry }, null, 2))
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
server.tool(
'templatize',
"Turn a company's slide format into a reusable unPaper TEMPLATE. Returns a payload for YOU to execute — there is no generation inside unPaper. With no brief: the template-maker prompt + the base skeleton (study the company's real slides in your context and produce the re-skin yourself). With a brief (name, colours, personality, logo…): the designer prompt + a pre-founded base for a company with NO existing format — that embedded base is already a valid template and may be saved as-is. After authoring, validate with `check` {template: true} and keep the repaired html as the company template every future deck starts from. Use `starter` for one-off decks; use this when the user wants THEIR OWN reusable format.",
{
family: z.enum(['standard', 'editorial', 'design', 'consulting']).optional().describe('base family (default: standard; design mode defaults to consulting)'),
name: z.string().optional().describe('company name (any brief field switches to design mode)'),
does: z.string().optional().describe('what the company does, one line'),
personality: z.enum(['modern', 'classic', 'technical', 'warm', 'bold']).optional(),
colors: z.array(z.string()).max(2).optional().describe('brand colours as hex, up to two'),
logoDataUri: z.string().optional().describe('logo as a data:image/* URI; SVG preferred'),
foot: z.string().optional().describe('footer line, e.g. "Confidential"'),
present: z.array(z.enum(['proposals', 'strategy', 'research', 'sales', 'board'])).optional().describe('what the company mostly presents'),
notes: z.string().optional(),
},
async (args) => {
try {
const { templatize } = await import('./templatize.mjs')
const res = templatize(args)
log(`templatize: ${res.mode} payload, ${res.baseFamily} base, ${res.payload.length} chars`)
return text(res.payload)
} catch (err) {
return errText(String(err.message ?? err))
}
},
)
const transport = new StdioServerTransport()
await server.connect(transport)
log(`unpaper-cli/${VERSION}, contract v2 — 7 tools ready (stdio)`)
}
/**
* templatize (T6, plans/COMPANY-TEMPLATE-PLAN.md) — the agent path to a
* company template. Emits the SAME payloads /brand builds, for the agent to
* execute as its own authoring turn (never generation inside unPaper):
* · re-skin mode (no brief): the template-maker prompt + the base skeleton;
* the agent studies the company's real slides in its own context.
* · design mode (any brief field): the designer prompt + the FOUNDED base
* (brief baked deterministically) — that base is already a valid template
* and can be saved as-is (the instant "plain start", CT-9).
* Validation of the authored result is `check --template` (check.mjs).
*/
import {
buildTemplateMakerPrompt,
buildTemplateDesignPrompt,
prepareFoundingBase,
EMPTY_DESIGN_BRIEF,
PERSONALITIES,
PRESENT_CHOICES,
LOGO_MAX_CHARS,
} from '../dist/core.mjs'
import { starter, FAMILIES } from './library.mjs'
export const PERSONALITY_IDS = PERSONALITIES.map((p) => p.id)
export const PRESENT_IDS = PRESENT_CHOICES.map((p) => p.id)
/**
* @returns {{ mode: 'reskin'|'design', baseFamily: string, payload: string, founded?: string }}
*/
export function templatize({ family, name, does, personality, colors, logoDataUri, foot, present, notes } = {}) {
const briefGiven = Boolean(
name?.trim() || does?.trim() || personality || colors?.length || logoDataUri || foot?.trim() || present?.length || notes?.trim(),
)
const mode = briefGiven ? 'design' : 'reskin'
// CT-11: the from-scratch door defaults to consulting (the largest pattern
// vocabulary); the re-skin door mirrors /brand's standard default.
const fam = family ?? (mode === 'design' ? 'consulting' : 'standard')
if (!FAMILIES.includes(fam)) throw new Error(`Unknown family "${fam}" — one of: ${FAMILIES.join(', ')}.`)
const base = starter(fam)
if (mode === 'reskin') return { mode, baseFamily: fam, payload: buildTemplateMakerPrompt(base) }
if (personality && !PERSONALITY_IDS.includes(personality))
throw new Error(`Unknown personality "${personality}" — one of: ${PERSONALITY_IDS.join(', ')}.`)
const badPresent = (present ?? []).filter((p) => !PRESENT_IDS.includes(p))
if (badPresent.length) throw new Error(`Unknown present value(s) ${badPresent.join(', ')} — one of: ${PRESENT_IDS.join(', ')}.`)
if (logoDataUri && !/^data:image\//.test(logoDataUri))
throw new Error('logoDataUri must be a data:image/* URI (SVG preferred). Read the logo file and base64-encode it.')
if (logoDataUri && logoDataUri.length > LOGO_MAX_CHARS)
throw new Error(`Logo too large (${logoDataUri.length} chars > ${LOGO_MAX_CHARS}). Export it as SVG and retry.`)
const brief = {
...EMPTY_DESIGN_BRIEF,
name: name ?? '',
does: does ?? '',
personality: personality ?? '',
colors: colors ?? [],
foot: foot ?? '',
present: present ?? [],
notes: notes ?? '',
...(logoDataUri ? { logo: { dataUri: logoDataUri } } : {}),
}
const founded = prepareFoundingBase(base, brief)
return { mode, baseFamily: fam, payload: buildTemplateDesignPrompt(founded, brief), founded }
}

Sorry, the diff of this file is too big to display

<!DOCTYPE html>
<html lang="en" data-theme="midnight" data-bg="gradient" data-accent="violet" data-header="on" data-footer="on" data-footer-rule="on" data-page-numbers="on" data-canvas="auto" data-font="default" data-logo="off" data-eyebrow="Briefing" data-brand="Your brand" data-foot="Confidential" data-ref="2026">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Untitled deck – unPaper</title>
<!--
═══════════════════════════════════════════════════════════════════════════
unPaper · SLIDES · self-contained, print-ready 16:9 HTML · www.unpaper.ai
═══════════════════════════════════════════════════════════════════════════
HOW THIS FILE WORKS (read before editing)
• This is ONE file. No external CSS, JS, fonts or images. Keep it that way so
it prints identically offline. (Want a brand font/logo? Add it yourself.)
• PRIVATE & LOCAL. Fully self-contained – it makes no network requests and
sends nothing anywhere. It opens, edits and prints entirely on your own
machine, so it is safe for confidential content.
• Each slide is a fixed 1280 × 720 canvas – exactly PowerPoint 16:9. On screen
it scales to fit; printed, every <section class="slide"> becomes ONE PDF page.
• To save a PDF: open this file in a real browser (Chrome or Safari) → Cmd/Ctrl + P
→ "Save as PDF". On a phone: ⋯ / Share → Print → Save as PDF. Turn ON "Background
graphics" and leave margins on Default. (Printing from inside a chat app's built-in
browser often looks wrong – open the file in Chrome or Safari instead.)
• Present it: click "Present" in the toolbar, or open …/slides.html#present.
───────────────────────────────────────────────────────────────────────────
ASSISTANT RULES (you are an AI editing this deck – follow exactly)
───────────────────────────────────────────────────────────────────────────
THIS IS A WRITING TASK, NOT A TOOLING TASK. Edit this file's slide content and return the
HTML. Do not use any slide library (PptxGenJS etc.), deck/artifact builder, headless
browser or script to generate, render, export or preview it – unPaper does the
HTML→PowerPoint conversion and the preview itself.
THIS DECK IS AN EXAMPLE OF THE FORMAT – NOT A FIXED OUTLINE. Match its look, type
scale, layouts and print setup, but build AS MANY SLIDES AS THE CONTENT NEEDS –
more or fewer than these twelve. Add, remove and reorder slides freely; for each point
pick whichever example layout fits (cover · statement · divider · list · two-column ·
cards · chart · table · framework · quote · stats · closing). For any data or comparison, use the chart (a real <svg> +
data-unpaper-chart) or the table (a real <table>) – they convert to native, editable
PowerPoint objects. The example's words are placeholders – replace them entirely.
KEEP INTACT, NEVER CHANGE: the <style> block, the @page rule, the .slide
1280×720 sizing, and the <script> at the bottom (it scales + auto-fits slides).
EDIT ONLY the slide content inside <section class="slide"> … </section>.
NO NEW JAVASCRIPT: the <script> already in this file is the only one allowed,
and no CONTENT may depend on it. Every word, number and image must exist in
the plain HTML/CSS itself (images as data: URIs or CSS backgrounds – never a
src set by a script). unPaper's HTML→PPTX converter reads the file with
scripts disabled, so anything scripted simply disappears from the PowerPoint.
HEADER / FOOTER: the running brand (top-right), footer label (bottom-left) and
cover date come from <html data-brand="…" data-foot="…" data-ref="…">. Set those
to the user's own values – they fill every .t-brand / .t-foot / .t-ref slot. Do
NOT type a brand into each slide by hand.
TO ADD A SLIDE: copy a whole <section class="slide" data-kind="…"> block and
edit its text. Reuse the provided layouts – do not invent new CSS classes.
FIT THE PAGE – the #1 rule. Each slide must hold comfortably in 1280×720 with
air around it. Respect these budgets (one IDEA per slide):
• Slide title: ≤ 10 words. • Body: ≤ ~55 words total per slide.
• Bulleted list: ≤ 5 items, ≤ 12 words each. • Columns/cards: 2–3 max.
• Stat figures: ≤ 4 per slide.
If a slide feels full, SPLIT it into two – never shrink to cram (overflowing
content is auto-shrunk to fit, but a crammed slide still reads worse).
TASTE – make it look designed, not "AI-generated":
• No emoji. No purple gradients. No clip-art. No drop-shadow soup.
• Lean on the type scale; you may size down for sources, captions and dense tables (~12px floor).
• One accent colour, used sparingly. Generous whitespace > more content.
• Sentence case. Specific nouns and numbers over adjectives.
LOOK: all visual options live on the <html> tag – change attributes, not CSS.
• data-theme = midnight · paper · slate · white (white = pure, no wash)
• data-bg = grid · dots · gradient · plain (slide background)
• data-accent = default · gold · teal · blue · coral · violet
• data-header / data-footer / data-footer-rule / data-page-numbers = on · off
• data-canvas = auto · custom (custom uses --custom-* vars; for pure white pick the "paper" theme)
• data-font = default · modern · classic · technical · humanist
═══════════════════════════════════════════════════════════════════════════
-->
<style>
/* ════════════════════════════════════════════════════════════════
SKIN LAYER — palette · fonts · accent. A new template skin
(Consulting, Executive Brief…) overrides ONLY these tokens; the
SKELETON below (geometry, components, script) is shared verbatim,
so every skin converts identically. See /TEMPLATES.md.
════════════════════════════════════════════════════════════════ */
/* ─── THEME TOKENS ─────────────────────────────────────────────── */
:root{
--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--display:var(--sans);
--num:var(--sans);
--serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
--mono:ui-monospace,SFMono-Regular,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--label:var(--mono);
}
html[data-font="modern"]{--sans:Aptos,"Segoe UI",system-ui,-apple-system,sans-serif;--display:Aptos,"Segoe UI",system-ui,-apple-system,sans-serif;--serif:Aptos,"Segoe UI",system-ui,-apple-system,sans-serif;--num:Aptos,"Segoe UI",system-ui,sans-serif;--label:var(--mono)}
html[data-font="classic"]{--sans:"Gill Sans","Trebuchet MS","Segoe UI",system-ui,sans-serif;--display:Georgia,"Times New Roman",serif;--serif:Georgia,"Times New Roman",serif;--num:Georgia,"Times New Roman",serif;--label:var(--sans)}
html[data-font="technical"]{--sans:"SF Pro Text","Segoe UI",system-ui,sans-serif;--display:"SF Pro Display","Segoe UI",system-ui,sans-serif;--serif:"SF Pro Display","Segoe UI",system-ui,sans-serif;--num:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;--label:var(--mono)}
html[data-font="humanist"]{--sans:"Avenir Next","Trebuchet MS","Segoe UI",system-ui,sans-serif;--display:"Avenir Next","Trebuchet MS","Segoe UI",system-ui,sans-serif;--serif:"Iowan Old Style",Georgia,serif;--num:var(--sans);--label:var(--sans)}
html[data-theme="midnight"]{
--bg:#0a0a0f; --bg-2:#000000; --panel:#15151d; --ink:#f7f8fc; --ink-soft:#b7b9c8;
--ink-dim:#74768a; --ink-faint:#494b59; --line:rgba(150,155,185,.16); --line-soft:rgba(150,155,185,.07);
--accent:#ffd24a; --accent-ink:#0a0a0f; --grid:rgba(150,155,190,.05);
}
html[data-theme="paper"]{
--bg:#fcfbf7; --bg-2:#e7e1d3; --panel:#ffffff; --ink:#191917; --ink-soft:#45433d;
--ink-dim:#8b897f; --ink-faint:#b4b1a6; --line:rgba(30,28,22,.14); --line-soft:rgba(30,28,22,.07);
--accent:#0f7a6b; --accent-ink:#ffffff; --grid:rgba(30,28,22,.035);
}
html[data-theme="slate"]{
--bg:#eef1f4; --bg-2:#d2dae2; --panel:#ffffff; --ink:#1b2430; --ink-soft:#465160;
--ink-dim:#7d8794; --ink-faint:#aab2bd; --line:rgba(27,36,48,.14); --line-soft:rgba(27,36,48,.06);
--accent:#2563a8; --accent-ink:#ffffff; --grid:rgba(27,36,48,.035);
}
html[data-theme="white"]{ /* pure white: a clean, unwashed canvas */
--bg:#ffffff; --bg-2:#eceff3; --panel:#ffffff; --ink:#111827; --ink-soft:#4b5563;
--ink-dim:#6b7280; --ink-faint:#9ca3af; --line:rgba(17,24,39,.14); --line-soft:rgba(17,24,39,.07);
--accent:#0f7a6b; --accent-ink:#ffffff; --grid:rgba(17,24,39,.04);
}
/* ─── ACCENT OVERRIDES (data-accent) – shades tuned per light/dark theme ── */
html[data-accent="gold"]{--accent:#b8860b;--accent-ink:#ffffff}
html[data-accent="teal"]{--accent:#0f7a6b;--accent-ink:#ffffff}
html[data-accent="blue"]{--accent:#2563a8;--accent-ink:#ffffff}
html[data-accent="coral"]{--accent:#c2452f;--accent-ink:#ffffff}
html[data-accent="violet"]{--accent:#6d4fc4;--accent-ink:#ffffff}
html[data-theme="midnight"][data-accent="gold"]{--accent:#ffd24a;--accent-ink:#0a1620}
html[data-theme="midnight"][data-accent="teal"]{--accent:#56cdbb;--accent-ink:#0a1620}
html[data-theme="midnight"][data-accent="blue"]{--accent:#7fb3ef;--accent-ink:#0a1620}
html[data-theme="midnight"][data-accent="coral"]{--accent:#ff8a70;--accent-ink:#0a1620}
html[data-theme="midnight"][data-accent="violet"]{--accent:#b9a6ff;--accent-ink:#0a0a0f}
/* ─── ADVANCED TEMPLATE SETTINGS (data-* on <html>) ───────────────── */
html[data-canvas="custom"]{
--bg:var(--custom-bg,#ffffff);
--bg-2:color-mix(in srgb,var(--custom-bg,#ffffff) 78%,#000 22%);
--panel:var(--custom-panel,#ffffff);
--ink:var(--custom-ink,#111827);
--ink-soft:var(--custom-ink-soft,#4b5563);
--ink-dim:color-mix(in srgb,var(--custom-ink-soft,#4b5563) 78%,transparent);
--ink-faint:color-mix(in srgb,var(--custom-ink-soft,#4b5563) 52%,transparent);
--line:var(--custom-line,#cbd5e1);
--line-soft:color-mix(in srgb,var(--custom-line,#cbd5e1) 45%,transparent);
--accent:var(--custom-accent,#2563a8);
--accent-ink:var(--custom-accent-ink,#ffffff);
--grid:color-mix(in srgb,var(--custom-line,#cbd5e1) 32%,transparent);
}
/* ════════════════════════════════════════════════════════════════
SKELETON — shared by every skin: geometry, page flow, the component
library, present/print modes, and the engine script. Change with care.
════════════════════════════════════════════════════════════════ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg-2);color:var(--ink);font-family:var(--sans);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
aside.notes{display:none} /* per-slide speaker notes: invisible here, become real notes in PowerPoint */
/* ─── PAGE FLOW (vertical stack on screen) ─────────────────────── */
.deck{display:flex;flex-direction:column;align-items:center;gap:30px;padding:46px 24px 120px}
.slide-frame{width:min(100%,1280px);flex-shrink:0} /* responsive box */
.slide{
width:1280px;height:720px;position:relative;overflow:hidden;
transform-origin:top left; /* scaled by script */
background:var(--bg);color:var(--ink);
box-shadow:0 18px 50px rgba(0,0,0,.28);border-radius:2px;
}
/* background: a soft corner glow always; the pattern depends on data-bg */
.slide::before{
content:"";position:absolute;inset:0;pointer-events:none;z-index:0;
background:radial-gradient(circle at 14% 8%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 38%);
}
/* The white theme is PURE: no default glow. (Placed before the data-bg rules,
so an explicitly chosen grid/dots/gradient background still renders.) */
html[data-theme="white"] .slide::before{background:none}
html[data-bg="grid"] .slide::before{
background:
radial-gradient(circle at 14% 8%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 38%),
linear-gradient(var(--grid) 1px,transparent 1px),
linear-gradient(90deg,var(--grid) 1px,transparent 1px);
background-size:auto,44px 44px,44px 44px;
}
html[data-bg="dots"] .slide::before{
background:
radial-gradient(circle at 14% 8%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 38%),
radial-gradient(color-mix(in srgb, var(--ink-dim) 22%, transparent) 1.6px, transparent 1.7px);
background-size:auto,30px 30px;
}
html[data-bg="gradient"] .slide::before{
background:
radial-gradient(circle at 14% 8%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 44%),
linear-gradient(150deg, color-mix(in srgb, var(--accent) 12%, transparent), transparent 46%),
linear-gradient(330deg, color-mix(in srgb, var(--accent) 6%, transparent), transparent 40%);
}
html[data-canvas="custom"][data-bg="gradient"] .slide::before{
background:
radial-gradient(circle at 14% 8%, color-mix(in srgb,var(--custom-gradient-glow,var(--custom-accent,#2563a8)) 22%,transparent), transparent 42%),
linear-gradient(var(--custom-gradient-angle,150deg), color-mix(in srgb,var(--custom-gradient-from,var(--custom-bg,#ffffff)) 42%,transparent), transparent 56%),
linear-gradient(330deg, color-mix(in srgb,var(--custom-gradient-to,var(--custom-accent,#2563a8)) 24%,transparent), transparent 48%);
}
.pad{position:relative;z-index:1;height:100%;display:flex;flex-direction:column;padding:54px 72px 44px}
/* header / footer rails */
.rail{flex-shrink:0;display:flex;justify-content:space-between;align-items:center;
font-family:var(--mono);font-size:13px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim)}
.rail--top{margin-bottom:26px}
.rail--bot{margin-top:26px;padding-top:14px;border-top:1px solid var(--line)}
.rail .n{color:var(--accent);font-weight:700}
.t-brand{color:var(--ink-soft);font-weight:600}
html[data-header="off"] .rail--top{display:none!important}
html[data-footer="off"] .rail--bot{display:none!important}
html[data-footer-rule="off"] .rail--bot{border-top-color:transparent!important}
html[data-page-numbers="off"] .rail .n,
html[data-page-numbers="off"] .rail--bot span:not(.t-foot):not(.t-ref){display:none!important}
/* flexible middle – auto-fit operates here */
.fit{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:flex-start}
.fit-i{width:100%;margin:auto 0} /* center when it fits; pin to top (never over the rail) when content overflows */
/* type scale (designed against the 720 canvas) */
.eyebrow{font-family:var(--mono);color:var(--accent);font-weight:700;text-transform:uppercase;
letter-spacing:.24em;font-size:15px;position:relative;display:block;padding-left:42px}
.eyebrow::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:30px;height:2px;background:var(--accent)} /* absolute → converts to a native, movable shape */
.title{font-family:var(--serif);font-weight:400;line-height:1.04;letter-spacing:-.015em;color:var(--ink)}
.title em{font-style:italic;color:var(--accent)}
h2.title{font-size:54px;max-width:34ch}
.lede{font-family:var(--serif);font-weight:400;font-size:25px;line-height:1.5;color:var(--ink-soft);max-width:74ch}
.lede strong{color:var(--ink);font-weight:600}
.lede em{color:var(--accent);font-style:italic}
.kicker{font-family:var(--serif);font-size:34px;line-height:1.28;color:var(--ink);max-width:30ch}
.kicker em{color:var(--accent);font-style:italic}
/* cover */
.cover .pad{justify-content:flex-start}
.cover .fit{justify-content:flex-start}
.cover-title{font-family:var(--serif);font-weight:300;font-size:96px;line-height:.98;letter-spacing:-.02em;max-width:20ch}
.cover-title em{font-style:italic;color:var(--accent);font-weight:400}
.cover-lede{margin-top:30px;font-family:var(--serif);font-weight:300;font-size:27px;line-height:1.5;color:var(--ink-soft);max-width:70ch}
.cover-lede strong{color:var(--ink);font-weight:500}
/* numbered statement list */
.rows{display:flex;flex-direction:column}
.row{display:grid;grid-template-columns:84px 1fr;gap:30px;align-items:baseline;padding:22px 0;border-top:1px solid var(--line)}
.row:last-child{border-bottom:1px solid var(--line)}
.row .rn{font-family:var(--serif);font-style:italic;font-weight:300;font-size:52px;color:var(--accent);line-height:1}
.row h3{font-family:var(--serif);font-weight:500;font-size:27px;line-height:1.2;color:var(--ink);margin-bottom:5px}
.row p{font-size:19px;line-height:1.5;color:var(--ink-soft);max-width:80ch}
.row p em{color:var(--accent);font-style:italic}
/* cards / columns */
.cards{display:grid;gap:22px}
.cards.c2{grid-template-columns:1fr 1fr}
.cards.c3{grid-template-columns:1fr 1fr 1fr}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:28px 26px;display:flex;flex-direction:column}
.card .ci{font-family:var(--mono);font-size:13px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:14px}
.card h3{font-family:var(--serif);font-weight:500;font-size:25px;line-height:1.18;color:var(--ink);margin-bottom:10px}
.card p{font-size:18px;line-height:1.5;color:var(--ink-soft)}
/* icon — inline SVG → a native, recolorable PowerPoint shape. A single bold
stroke icon (M/L/H/V/C/Q/Z + line/polyline/polygon; no arc "A") can be the
hero of a slide; it converts to an outlined freeform in the accent. */
.ico{color:var(--accent);flex:none}
.ico--hero{width:172px;height:172px;stroke-width:1.25;display:block}
/* timeline / roadmap — phases across time. The rail and each milestone dot are
absolute pseudo-elements → they convert to native, movable PowerPoint shapes;
labels and text stay editable. Steps flow as columns, any count. */
.timeline{position:relative;display:grid;grid-auto-flow:column;grid-auto-columns:1fr;gap:24px}
.timeline::before{content:"";position:absolute;left:7px;right:7px;top:6px;height:2px;background:var(--line)}
.tl-step{position:relative}
.tl-step::before{content:"";position:absolute;left:0;top:0;width:14px;height:14px;border-radius:50%;background:var(--accent)}
.tl-step .when{font-family:var(--mono);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);font-weight:700;margin:26px 0 6px}
.tl-step h3{font-family:var(--display,var(--serif));font-weight:600;font-size:21px;line-height:1.2;color:var(--ink);margin-bottom:6px}
.tl-step p{font-size:16px;line-height:1.45;color:var(--ink-soft)}
/* callout — one thing to flag. Bordered panel → native shape + editable text. */
.callout{border-left:3px solid var(--accent);background:var(--panel);border-radius:8px;padding:20px 24px}
.callout .ci{font-family:var(--mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:8px}
.callout p{font-size:18px;line-height:1.5;color:var(--ink-soft)}
/* stat band */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:26px}
.stat{border-left:3px solid var(--accent);padding-left:22px}
.stat .v{font-family:var(--serif);font-weight:400;font-size:62px;line-height:1;color:var(--ink);letter-spacing:-.02em}
.stat .k{margin-top:12px;font-size:18px;line-height:1.45;color:var(--ink-soft)}
/* comparison table → native PowerPoint table. Use a real <table>; the
converter rebuilds it as an editable <a:tbl> (rows can be added in PPT). */
.tbl{width:100%;border-collapse:collapse;font-family:var(--sans)}
.tbl caption{text-align:left;font-family:var(--mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:16px}
.tbl th,.tbl td{text-align:left;padding:14px 20px;border-bottom:1px solid var(--line);font-size:18px;line-height:1.35;color:var(--ink-soft)}
.tbl thead th{font-family:var(--serif);font-weight:600;font-size:19px;color:var(--ink);border-bottom:2px solid var(--accent)}
.tbl tbody tr:last-child td{border-bottom:none}
.tbl th:first-child,.tbl td:first-child{font-weight:600;color:var(--ink)}
.tbl .num{text-align:right;font-variant-numeric:tabular-nums}
/* chart → native, data-editable PowerPoint chart. Draw it as inline SVG for
the web/print, and put the numbers on data-unpaper-chart so the converter
rebuilds it natively (with a real Excel sheet). 1 chart, or 2–4 small
multiples side by side via .charts.c2 / .c3. */
.charts{display:grid;gap:34px}
.charts.c2{grid-template-columns:1fr 1fr}
.charts.c3{grid-template-columns:repeat(3,1fr)}
.chart{max-width:820px} /* keep a lone chart within the fit area (no auto-fit scale, so it converts native) */
.charts .chart{max-width:none} /* small multiples fill their grid cell */
.chart svg{width:100%;height:auto;display:block;overflow:visible}
.chart .clabel{font-family:var(--mono);font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:10px}
.chart .cbar{fill:var(--accent)}
.chart .cgrid{stroke:var(--line-soft)}
.chart .ctick{fill:var(--ink-dim);font-family:var(--sans);font-size:14px}
/* section divider — a full-bleed chapter break */
.divider .fit{justify-content:flex-start}
.divider .dnum{font-family:var(--mono);font-size:14px;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);font-weight:700}
.divider .dtitle{font-family:var(--serif);font-weight:300;font-size:72px;line-height:1.04;letter-spacing:-.02em;margin-top:18px;max-width:24ch}
.divider .dsub{margin-top:22px;font-family:var(--serif);font-size:24px;line-height:1.4;color:var(--ink-soft);max-width:58ch}
/* two-column split — text beside text or a visual */
.split{display:grid;grid-template-columns:1fr 1fr;gap:50px;align-items:start}
.split .col h3{font-family:var(--serif);font-weight:500;font-size:26px;color:var(--ink);margin-bottom:12px}
.split .col p{font-size:19px;line-height:1.55;color:var(--ink-soft)}
.split .col + .col{border-left:1px solid var(--line);padding-left:50px}
/* 2×2 framework / matrix. Add .hot to the priority quadrant; add class
"axes" + three horizontal .mxlabel children (top/low/high) for a true
labelled matrix — labels convert to native text. */
.matrix{position:relative;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:16px;height:430px}
.matrix.axes{height:372px;margin:36px 0 30px}
.quad{border:1px solid var(--line);border-radius:12px;padding:26px;display:flex;flex-direction:column;background:var(--panel)}
.quad.hot{border-color:var(--accent);border-width:2px}
.quad .ql{font-family:var(--mono);font-size:12px;letter-spacing:.13em;text-transform:uppercase;color:var(--accent);font-weight:700;margin-bottom:10px}
.quad h4{font-family:var(--serif);font-weight:500;font-size:23px;color:var(--ink);margin-bottom:6px}
.quad p{font-size:16px;line-height:1.45;color:var(--ink-soft)}
.mxlabel{position:absolute;font-family:var(--mono);font-size:12px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim);font-weight:700;white-space:nowrap}
.mxlabel.top{left:50%;top:-30px;transform:translateX(-50%)}
.mxlabel.low{left:0;bottom:-28px}
.mxlabel.high{right:0;bottom:-28px}
/* org / hierarchy — one parent over a row of children. Boxes are native
shapes; the stem, bus and risers are absolute pseudo-elements → native
shapes; labels native text. Mark the parent .root. Best for 2-4 reports. */
.orgtree .org-node{background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:18px 22px;text-align:center}
.orgtree .org-node .ci{font-family:var(--mono);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700}
.orgtree .org-node h4{font-family:var(--serif);font-weight:500;font-size:21px;color:var(--ink);margin-top:6px}
.orgtree .org-node p{font-size:14px;line-height:1.4;color:var(--ink-soft);margin-top:5px}
.orgtree .root{border-color:var(--accent);border-width:2px;width:300px;margin:0 auto}
.org-children{display:flex;justify-content:center;gap:26px;margin-top:56px;position:relative}
.org-children::before{content:"";position:absolute;left:50%;top:-56px;width:2px;height:28px;background:var(--line);transform:translateX(-50%)}
.org-children::after{content:"";position:absolute;top:-28px;left:calc(50% - 346px);right:calc(50% - 346px);height:2px;background:var(--line)}
.org-children .org-node{position:relative;flex:1;max-width:320px}
.org-children .org-node::before{content:"";position:absolute;left:50%;top:-28px;width:2px;height:28px;background:var(--line);transform:translateX(-50%)}
/* icon-led list — parallel items, each marked by a small inline-SVG icon */
.feat{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}
.feat .it{display:flex;flex-direction:column;gap:14px}
.feat h3{font-family:var(--serif);font-weight:500;font-size:24px;line-height:1.18;color:var(--ink)}
.feat p{font-size:18px;line-height:1.5;color:var(--ink-soft)}
/* process flow: boxes joined by all-stroke SVG arrows (both go native) */
.flow{display:flex;align-items:center;gap:14px}
.flow .node{flex:1;background:var(--panel);border:1.5px solid var(--accent);border-radius:12px;padding:22px 20px}
.flow .node .ci{font-family:var(--mono);font-size:12px;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);font-weight:700}
.flow .node h3{font-family:var(--serif);font-weight:500;font-size:21px;color:var(--ink);margin-top:8px}
.flow .arrow{width:54px;height:22px;color:var(--accent);flex:none}
/* pros & cons — two bordered columns; the <ul>s convert to native bullets */
.proscons{display:grid;grid-template-columns:1fr 1fr;gap:28px}
.proscons .pc{background:var(--panel);border:1.5px solid var(--line);border-radius:12px;padding:24px 28px}
.proscons .pc h4{font-family:var(--serif);font-weight:500;font-size:21px;color:var(--ink);margin-bottom:14px}
.proscons .pc ul{list-style:none;display:flex;flex-direction:column;gap:10px}
.proscons .pc li{position:relative;padding-left:24px;font-size:17px;line-height:1.45;color:var(--ink-soft)}
.proscons .pc.pro li::before{content:"+";position:absolute;left:0;color:var(--accent);font-weight:700}
.proscons .pc.con li::before{content:"\2013";position:absolute;left:0;color:var(--ink-dim);font-weight:700}
/* big-number hero — one metric carries the slide */
.bignum .v{font-family:var(--serif);font-weight:400;font-size:180px;line-height:.92;color:var(--accent);letter-spacing:-.03em}
.bignum .k{margin-top:18px;font-size:24px;line-height:1.45;color:var(--ink-soft);max-width:32ch}
/* pull quote */
.quote .fit{justify-content:flex-start}
.pull{font-family:var(--serif);font-weight:300;font-size:44px;line-height:1.26;letter-spacing:-.01em;color:var(--ink);max-width:26ch}
.pull em{font-style:italic;color:var(--accent)}
.quote-by{margin-top:28px;font-family:var(--mono);font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-dim)}
/* ── LAYOUT GRAMMAR (data-unpaper-lay) ── split a slide body into a CSS-grid of
regions with ONE attribute; each direct child is a region; drop any block in
it (text, <ul>, a chart, a <table>, .card, .stat). Regions convert to native
objects at their grid positions. Many layouts, almost no markup. */
[data-unpaper-lay]{display:grid;gap:42px;align-items:start;width:100%}
[data-unpaper-lay="2"]{grid-template-columns:1fr 1fr}
[data-unpaper-lay="2l"]{grid-template-columns:7fr 5fr} /* wide left | narrow right (text | chart) */
[data-unpaper-lay="2r"]{grid-template-columns:5fr 7fr} /* narrow left | wide right */
[data-unpaper-lay="3"]{grid-template-columns:repeat(3,1fr)}
[data-unpaper-lay="rail"]{grid-template-columns:4fr 8fr} /* label rail | main */
[data-unpaper-lay="quad"]{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:16px}
[data-unpaper-lay] > *{min-width:0}
.closing .fit{justify-content:flex-start}
/* Overflowing content is silently shrunk to fit by the autofit script — no
on-slide warning, so a finished deck never shows authoring marks. */
/* ─── floating toolbar (never prints) ──────────────────────────── */
.bar{position:fixed;z-index:60;left:50%;bottom:18px;transform:translateX(-50%);
display:flex;flex-wrap:nowrap;align-items:center;gap:3px;
max-width:calc(100vw - 16px);overflow-x:auto;scrollbar-width:none;padding:6px 7px;border-radius:999px;
background:rgba(12,20,28,.86);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.12)}
.bar::-webkit-scrollbar{display:none}
.bar > *{flex:0 0 auto}
.bar button{font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;
color:#dfe6ef;background:transparent;border:0;border-radius:999px;padding:7px 11px;cursor:pointer;transition:background .15s}
.bar button:hover{background:rgba(255,255,255,.12)}
.bar button.on{background:var(--accent);color:var(--accent-ink);font-weight:700}
.bar .sep{width:1px;height:18px;background:rgba(255,255,255,.16)}
/* theme dots — each carries its theme's accent as a ring so the light shades
stay distinguishable; the active theme gets a bright halo. */
.bar .nav{font-size:16px;line-height:1;padding:4px 9px}
.bar .count{font-family:var(--mono);font-size:11px;color:#9fb0c4;letter-spacing:.08em;min-width:40px;text-align:center}
/* collapsed (clean view): hide everything except the show/hide toggle */
.bar.min{padding:5px}
.bar.min > *:not(#bHide){display:none}
/* keep the toolbar on one row on small phones: drop the dividers so it fits */
@media (max-width:480px){ .bar{gap:2px} .bar .sep{display:none} .bar button{padding:6px 9px} }
/* Touch devices: browser print letterboxes the fixed 16:9 page (iOS forces paper sizes),
so hide "Save PDF" and point to a computer. The unPaper app's PDF export stays mobile-safe. */
#bPrintHint{display:none}
@media (hover:none) and (pointer:coarse){ #bPrint{display:none} #bPrintHint{display:inline;font-family:ui-monospace,monospace;font-size:11px;letter-spacing:.04em;color:#9fb0c4;white-space:nowrap;padding:0 6px} }
@media (max-width:640px){ #bPrint{display:none} }
.np{display:none}
body.present .bar .np{display:inline-flex;align-items:center}
body.present .bar span.np{display:inline-block}
/* ─── PRESENT MODE (one slide, fits the visible viewport, never clipped) ─ */
body.present{overflow:hidden}
/* speaker-notes overlay for present mode (E6.2) — inert unless body.present.notes-on, so scripts-off/print/convert never see it */
.notesbar{display:none}
/* in-window present nav (buttons exist only when the script builds them) */
.pnav{display:none}
body.present .pnav{display:flex;position:fixed;top:50%;transform:translateY(-50%);z-index:60;width:44px;height:44px;align-items:center;justify-content:center;border:0;border-radius:999px;background:rgba(17,19,25,.5);color:#fff;font:400 26px/1 ui-sans-serif,system-ui,sans-serif;cursor:pointer;opacity:.4;transition:opacity .15s}
body.present .pnav:hover{opacity:.95}
.pnav-l{left:14px}.pnav-r{right:14px}
body.present.notes-on .notesbar{display:block;position:fixed;left:0;right:0;bottom:0;z-index:70;max-height:36vh;overflow:auto;padding:16px 26px;background:#14161c;color:#fff;font:400 16px/1.55 ui-sans-serif,system-ui,sans-serif;box-shadow:0 -8px 30px rgba(0,0,0,.4)}
.notesbar b{display:block;font:700 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;opacity:.55;margin-bottom:7px}
.notesbar .empty{opacity:.5;font-style:italic}
body.present .deck{padding:0;gap:0}
body.present .slide-frame{display:none}
body.present .slide-frame.live{display:flex;position:fixed;top:0;left:0;width:100%;height:100dvh;
align-items:center;justify-content:center;overflow:hidden;z-index:40;background:var(--bg-2)}
/* keep the slide at its true 1280×720 inside the flex frame – never let it shrink to the viewport */
body.present .slide-frame.live .slide{flex:0 0 auto}
/* ─── PRINT – every slide is exactly one 16:9 page ─────────────── */
@page{size:338.67mm 190.5mm;margin:0}
@media print{
html,body{background:#fff}
.deck{display:block;padding:0;gap:0}
.slide-frame{width:auto;display:block!important}
.slide{transform:none!important;width:1280px;height:720px;box-shadow:none;border-radius:0;
break-after:page;page-break-after:always}
.slide-frame:last-child .slide{break-after:auto;page-break-after:auto}
.bar{display:none!important}
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
}
/* ════════════════════════════════════════════════════════════════
KEYNOTE SPOTLIGHT — the DESIGN skin's own language. Overrides the shared
skeleton's serif display with bold sans, near-black stage, huge type, one
hero per slide. These rules come AFTER the skeleton so they win. ════════ */
.cover-title,.title,.dtitle,.spot,.kicker,.pull{font-family:var(--sans)}
.cover-title,.title,.dtitle,.spot{font-weight:800;letter-spacing:-.035em;line-height:1.02}
.cover-title{font-size:104px;font-weight:850;max-width:18ch}
.cover-title em,.title em,.dtitle em,.spot em,.pull em{font-style:normal;color:var(--accent)}
.kicker{font-weight:600;letter-spacing:-.02em}
.kicker em{font-style:normal;color:var(--accent)}
.pull{font-weight:700;letter-spacing:-.02em}
/* spotlight — one huge line given the whole stage */
.spot{font-size:74px;line-height:1.0;color:var(--ink);max-width:17ch}
.spot .sub{display:block;margin-top:26px;font-size:23px;font-weight:500;letter-spacing:-.01em;line-height:1.5;color:var(--ink-soft);max-width:46ch}
/* full-bleed accent panel — stands in for an image */
.spotpanel{background:var(--accent);color:var(--accent-ink);border-radius:22px;padding:58px 60px;display:flex;flex-direction:column;justify-content:center;min-height:360px}
.spotpanel .lab{font-family:var(--mono);font-size:13px;letter-spacing:.18em;text-transform:uppercase;font-weight:700;opacity:.78}
.spotpanel h2{font-family:var(--sans);font-weight:800;font-size:52px;line-height:1.05;letter-spacing:-.03em;margin-top:14px}
/* three principles — big numbered rows, no boxes */
.principles{display:grid;gap:26px}
.principles .pr{display:flex;gap:26px;align-items:baseline;border-top:1px solid var(--line);padding-top:22px}
.principles .pr .pn{font-family:var(--sans);font-weight:800;font-size:38px;color:var(--accent);line-height:1;flex:none;width:62px}
.principles .pr h3{font-family:var(--sans);font-weight:700;font-size:27px;letter-spacing:-.02em;color:var(--ink);margin-bottom:6px}
.principles .pr p{font-size:18px;line-height:1.5;color:var(--ink-soft);max-width:62ch}
/* E5.2 running brand logo: painted once from --brand-logo, shown when data-logo=on (replaces the running brand text) */
html[data-logo="on"] .slide::after{content:"";position:absolute;top:48px;right:72px;width:156px;height:26px;background:var(--brand-logo) no-repeat right center;background-size:contain;z-index:6;pointer-events:none}
html[data-logo="on"] .t-brand{display:none}
</style>
</head>
<body>
<div class="deck" id="deck">
<!-- 01 · COVER (hero) -->
<div class="slide-frame"><section class="slide cover" data-kind="cover" data-starter>
<div class="pad">
<div class="rail rail--top"><span class="eyebrow t-eye"></span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<h1 class="cover-title">Make them <em>feel</em> it</h1>
<p class="cover-lede">One idea per slide, given the whole stage. Big type, deep space, a single accent doing all the pointing — built to land, not just to inform.</p>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span class="t-ref"></span></div>
</div>
<aside class="notes">Welcome. One idea per slide is the whole method, and this deck practices what it preaches.</aside>
</section></div>
<!-- 02 · SPOTLIGHT (one huge line — the workhorse) -->
<div class="slide-frame"><section class="slide" data-kind="statement" data-starter>
<div class="pad">
<div class="rail rail--top"><span><span class="n">02</span> The idea</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<span class="eyebrow">Spotlight</span>
<p class="spot" style="margin-top:22px">Say the <em>one thing</em>.<span class="sub">If a slide carries two ideas, they each deserve their own. Subtract until only the point is left.</span></p>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>02 / 11</span></div>
</div>
<aside class="notes">If a slide carries two ideas, split it. Subtraction is the discipline.</aside>
</section></div>
<!-- 03 · THREE PRINCIPLES (big numbered rows, no boxes) -->
<div class="slide-frame"><section class="slide" data-kind="cards" data-starter>
<div class="pad">
<div class="rail rail--top"><span><span class="n">03</span> The approach</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<h2 class="title" style="font-size:42px;margin-bottom:30px">Three principles, nothing more</h2>
<div class="principles">
<div class="pr"><div class="pn">01</div><div><h3>One idea per slide</h3><p>Give the single point the whole stage and room to breathe.</p></div></div>
<div class="pr"><div class="pn">02</div><div><h3>Type does the work</h3><p>Scale and weight set the hierarchy — not boxes, rules or clutter.</p></div></div>
<div class="pr"><div class="pn">03</div><div><h3>One accent, used rarely</h3><p>Keep 90% of the ink quiet so the 10% can shout.</p></div></div>
</div>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>03 / 11</span></div>
</div>
<aside class="notes">Three principles: one idea per slide, type sets the hierarchy, one accent does all the pointing.</aside>
</section></div>
<!-- 04 · ONE BIG NUMBER -->
<div class="slide-frame"><section class="slide" data-kind="stats" data-tier="1">
<div class="pad">
<div class="rail rail--top"><span><span class="n">04</span> Focus</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<div style="font-family:var(--sans);font-weight:800;font-size:208px;line-height:.86;letter-spacing:-.05em;color:var(--accent)">1</div>
<p class="lede" style="margin-top:16px;font-size:30px;max-width:22ch">One number, given the whole stage, lands harder than a wall of them.</p>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>04 / 11</span></div>
</div>
</section></div>
<!-- 05 · ACCENT PANEL (a claim on a full accent block — stands in for an image) -->
<div class="slide-frame"><section class="slide" data-kind="split" data-starter>
<div class="pad">
<div class="rail rail--top"><span><span class="n">05</span> The claim</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<div class="spotpanel">
<div class="lab">Insight</div>
<h2>Attention is the budget. Spend it on one thing — and they remember it.</h2>
</div>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>05 / 11</span></div>
</div>
<aside class="notes">Attention is a budget. Spend it on one thing and the room remembers that thing.</aside>
</section></div>
<!-- 06 · PULL QUOTE -->
<div class="slide-frame"><section class="slide quote" data-kind="quote" data-tier="2">
<div class="pad">
<div class="rail rail--top"><span><span class="n">06</span> A line</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<p class="pull" style="font-size:54px">Good design is as little design as <em>possible</em>.</p>
<p class="quote-by">— Dieter Rams</p>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>06 / 11</span></div>
</div>
</section></div>
<!-- 07 · CONTRAST (from → to) -->
<div class="slide-frame"><section class="slide" data-kind="split" data-tier="2">
<div class="pad">
<div class="rail rail--top"><span><span class="n">07</span> The shift</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<div data-unpaper-lay="2" style="gap:64px;align-items:center">
<div><div class="eyebrow" style="color:var(--ink-dim)">From</div><p class="title" style="font-size:44px;margin-top:12px;color:var(--ink-soft)">A wall of bullets nobody recalls.</p></div>
<div><div class="eyebrow">To</div><p class="title" style="font-size:44px;margin-top:12px">One line they can&rsquo;t forget.</p></div>
</div>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>07 / 11</span></div>
</div>
</section></div>
<!-- 08 · SECTION DIVIDER (full-bleed) -->
<div class="slide-frame"><section class="slide divider" data-kind="divider" data-starter>
<div class="pad">
<div class="rail rail--top"><span><span class="n">08</span> Next</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<div class="dnum">Part two</div>
<h2 class="dtitle">Now make it <em>yours</em>.</h2>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>08 / 11</span></div>
</div>
<aside class="notes">Part two is yours: same stage, your idea.</aside>
</section></div>
<!-- 09 · STAT TRIO -->
<div class="slide-frame"><section class="slide" data-kind="stats" data-tier="3">
<div class="pad">
<div class="rail rail--top"><span><span class="n">09</span> Proof</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<h2 class="title" style="font-size:38px;margin-bottom:34px">Three numbers, no more</h2>
<div class="stats">
<div class="stat"><div class="v" style="font-family:var(--sans);font-weight:800">3&times;</div><div class="k">recall versus a bulleted slide.</div></div>
<div class="stat"><div class="v" style="font-family:var(--sans);font-weight:800">1</div><div class="k">idea carried per slide.</div></div>
<div class="stat"><div class="v" style="font-family:var(--sans);font-weight:800">90%</div><div class="k">of the ink kept deliberately quiet.</div></div>
</div>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>09 / 11</span></div>
</div>
</section></div>
<!-- FREE CANVAS · bare stage, compose anything -->
<div class="slide-frame"><section class="slide" data-kind="canvas" data-tier="3">
<div class="pad">
<div class="rail rail--top"><span><span class="n">10</span> Open canvas</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<!-- FREE CANVAS: keep this frame; replace everything inside with your own composition. Big type, a bordered or gradient panel, inline SVG, a table or a data-unpaper-chart all convert to native, editable objects. -->
<div style="border:2px dashed var(--line);min-height:380px;display:flex;align-items:center;justify-content:center;text-align:center;padding:40px">
<p style="font-size:22px;line-height:1.5;color:var(--ink-dim);max-width:42ch">Free canvas — keep the frame, then stage anything you like here. Big type, a panel, inline SVG, a table or a chart all still convert to native, editable objects.</p>
</div>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>10 / 11</span></div>
</div>
</section></div>
<!-- 10 · CLOSING -->
<div class="slide-frame"><section class="slide closing" data-kind="closing" data-starter>
<div class="pad">
<div class="rail rail--top"><span><span class="n">11</span> Close</span><span class="t-brand"></span></div>
<div class="fit"><div class="fit-i">
<span class="eyebrow">The craft</span>
<h2 class="title" style="margin-top:18px;font-size:68px">Restraint <em>is</em> the design.</h2>
<p class="lede" style="margin-top:22px">Replace this with your one idea — and resist adding a second.</p>
</div></div>
<div class="rail rail--bot"><span class="t-foot"></span><span>11 / 11</span></div>
</div>
<aside class="notes">Restraint is the design. Replace this with your one idea and resist adding a second.</aside>
</section></div>
</div>
<!-- toolbar – remove this whole block if you don't want it. It never prints. -->
<div class="bar" id="bar">
<button id="mScroll" class="on">Scroll</button>
<button id="mPresent">Present</button>
<span class="sep np"></span>
<button id="prev" class="nav np" title="Previous (←)">‹</button>
<span id="count" class="count np">1 / 6</span>
<button id="next" class="nav np" title="Next (→)">›</button>
<span class="sep"></span>
<button id="bPrint">Save PDF</button>
<span id="bPrintHint">Save a PDF on a computer</span>
<button id="bHide" class="nav" title="Hide controls" aria-label="Hide controls">▾</button>
</div>
<script>
/* unPaper slide engine – header/footer fill, scaling, auto-fit, present mode. Do not remove. */
(function(){
var DESIGN_W=1280, DESIGN_H=720, FLOOR=0.4;
var frames=[].slice.call(document.querySelectorAll('.slide-frame'));
/* use the *visible* viewport so mobile address bars / in-app browsers can't clip the slide */
function vv(){var v=window.visualViewport;return v?{w:v.width,h:v.height}:{w:window.innerWidth,h:window.innerHeight};}
function barReserve(){var b=document.getElementById('bar');return b?b.offsetHeight+30:80;}
function fillMeta(){
var d=document.documentElement.dataset;
document.querySelectorAll('.t-eye').forEach(function(e){e.textContent=d.eyebrow||''});
document.querySelectorAll('.t-brand').forEach(function(e){e.textContent=d.brand||''});
document.querySelectorAll('.t-foot').forEach(function(e){e.textContent=d.foot||''});
document.querySelectorAll('.t-ref').forEach(function(e){e.textContent=d.ref||''});
numberPages();
}
/* page numbers come from the slide's real position, not from whatever the AI
typed – so a model that numbers content from 1 can't produce 1/6…5/6.
The cover carries no page-number span, so it stays unnumbered; the first
content slide is 02 / N (N counts the cover). data-page-numbers="off" still
hides them via CSS. */
function numberPages(){
var total=frames.length, width=String(total).length;
function pad(n){var s=''+n;while(s.length<width)s='0'+s;return s;}
frames.forEach(function(f,i){
var pos=pad(i+1);
var top=f.querySelector('.rail .n'); if(top)top.textContent=pos;
var bot=f.querySelector('.rail--bot span:not(.t-foot):not(.t-ref)');
if(bot)bot.textContent=pos+' / '+total;
});
}
function scaleToFrames(){
var present=document.body.classList.contains('present');
var v=vv(), reserve=present?barReserve():0;
frames.forEach(function(f){
var slide=f.querySelector('.slide'), s;
if(present){
/* contain the whole slide inside the visible area – shrink to fit, never clip */
s=Math.min((v.w-24)/DESIGN_W, (v.h-reserve-16)/DESIGN_H);
if(!(s>0))s=0.1;
slide.style.transformOrigin='center center';
slide.style.transform='scale('+s+')';
f.style.paddingTop='8px';f.style.paddingBottom=reserve+'px';f.style.height='';
}else{
s=f.clientWidth/DESIGN_W;
slide.style.transformOrigin='top left';
slide.style.transform='scale('+s+')';
f.style.paddingTop='';f.style.paddingBottom='';f.style.height=(DESIGN_H*s)+'px';
}
});
}
function autofit(){
frames.forEach(function(f){
var slide=f.querySelector('.slide');
slide.querySelectorAll('.fit').forEach(function(fit){
var inner=fit.querySelector('.fit-i'); if(!inner)return;
inner.style.transform='';inner.style.width='';
var availH=fit.clientHeight, needH=inner.scrollHeight;
var availW=fit.clientWidth, needW=inner.scrollWidth, s=1;
var sh=needH>availH+1?availH/needH:1, sw=needW>availW+1?availW/needW:1;
if(sh<1||sw<1){ s=Math.max(FLOOR, Math.min(sh,sw)); /* shrink content to fully fit – never clip, height AND width */
inner.style.transformOrigin='top left';
inner.style.transform='scale('+s+')';
inner.style.width=(100/s)+'%';
}
if(s<0.86) slide.setAttribute('data-overfull',''); else slide.removeAttribute('data-overfull');
});
});
}
function refit(){fillMeta();autofit();scaleToFrames();}
/* present-mode navigation */
var cur=0;
function show(i){
cur=Math.max(0,Math.min(frames.length-1,i));
frames.forEach(function(f,k){f.classList.toggle('live',k===cur)});
var c=document.getElementById('count');if(c)c.textContent=(cur+1)+' / '+frames.length;
scaleToFrames();
}
function setMode(present){
document.body.classList.toggle('present',present);
document.getElementById('mPresent').classList.toggle('on',present);
document.getElementById('mScroll').classList.toggle('on',!present);
if(present)show(cur); else frames.forEach(function(f){f.classList.remove('live')});
refit();
}
document.getElementById('mPresent').onclick=function(){setMode(true)};
document.getElementById('mScroll').onclick=function(){setMode(false)};
document.getElementById('prev').onclick=function(){show(cur-1)};
document.getElementById('next').onclick=function(){show(cur+1)};
document.getElementById('bPrint').onclick=function(){var p=document.body.classList.contains('present');setMode(false);setTimeout(function(){window.print();if(p)setMode(true)},80)};
/* hide / show the toolbar for a clean, control-free view */
var bHide=document.getElementById('bHide');
if(bHide)bHide.onclick=function(){
var min=document.getElementById('bar').classList.toggle('min');
bHide.textContent=min?'▴':'▾';
bHide.title=min?'Show controls':'Hide controls';
};
/* present-mode in-window nav arrows + speaker-notes overlay (E6.2, revised: present stays inside the window — no fullscreen request). Progressive enhancement: no JS = no present mode, so the converter/print never see any of this. */
var pnavL=document.createElement('button');pnavL.className='pnav pnav-l';pnavL.textContent='\u2039';pnavL.title='Previous (\u2190)';pnavL.setAttribute('aria-label','Previous slide');document.body.appendChild(pnavL);
var pnavR=document.createElement('button');pnavR.className='pnav pnav-r';pnavR.textContent='\u203a';pnavR.title='Next (\u2192)';pnavR.setAttribute('aria-label','Next slide');document.body.appendChild(pnavR);
pnavL.onclick=function(){show(cur-1)};pnavR.onclick=function(){show(cur+1)};
var notesbar=document.createElement('div');notesbar.className='notesbar';document.body.appendChild(notesbar);
function renderNotes(){var f=frames[cur],n=f&&f.querySelector('.notes');notesbar.textContent='';var h=document.createElement('b');h.textContent='Speaker notes \u00b7 '+(cur+1)+' / '+frames.length;notesbar.appendChild(h);var d=document.createElement('div'),t=n&&n.textContent?n.textContent.trim():'';if(t){d.textContent=t;}else{d.textContent='No notes on this slide.';d.className='empty';}notesbar.appendChild(d);}
function toggleNotes(){if(!document.body.classList.contains('present'))return;document.body.classList.toggle('notes-on');if(document.body.classList.contains('notes-on'))renderNotes();}
var _setMode=setMode;setMode=function(p){_setMode(p);if(!p)document.body.classList.remove('notes-on');};
var _show=show;show=function(i){_show(i);if(document.body.classList.contains('notes-on'))renderNotes();};
window.addEventListener('keydown',function(e){
if(!document.body.classList.contains('present'))return;
if(e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' ')show(cur+1);
if(e.key==='ArrowLeft'||e.key==='ArrowUp')show(cur-1);
if(e.key==='Escape')setMode(false);
if(e.key==='n'||e.key==='N')toggleNotes();
});
/* swipe between slides in present mode (phones) */
var tx=0,ty=0;
window.addEventListener('touchstart',function(e){var t=e.changedTouches[0];tx=t.clientX;ty=t.clientY;},{passive:true});
window.addEventListener('touchend',function(e){
if(!document.body.classList.contains('present'))return;
var t=e.changedTouches[0],dx=t.clientX-tx,dy=t.clientY-ty;
if(Math.abs(dx)>48&&Math.abs(dx)>Math.abs(dy))show(dx<0?cur+1:cur-1);
},{passive:true});
window.addEventListener('resize',refit);
window.addEventListener('orientationchange',function(){setTimeout(refit,180)});
if(window.visualViewport){window.visualViewport.addEventListener('resize',refit);window.visualViewport.addEventListener('scroll',refit);}
window.addEventListener('load',refit);
if(document.fonts&&document.fonts.ready)document.fonts.ready.then(refit);
/* Decks OPEN PRESENTING by default — but only for a person in a real browser:
top-level window, not automation (headless converters set navigator.webdriver),
so iframes/embeds and the html→pptx pipeline keep the scroll layout they
measure. Open …#scroll to start in the scroll view; …#present forces present
even inside an iframe. */
if(location.hash==='#scroll'){refit();}
else if(location.hash==='#present'||(self===top&&!navigator.webdriver)){setMode(true);}
else{refit();}
})();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en" data-theme="paper" data-bg="plain" data-accent="default" data-header="on" data-footer="on" data-footer-rule="on" data-page-numbers="on" data-canvas="auto" data-font="default" data-logo="off" data-eyebrow="Field report" data-brand="The Quarterly" data-foot="Working Hours" data-ref="No. 04 · 2026">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Untitled deck – unPaper Editorial</title>
<!--
═══════════════════════════════════════════════════════════════════════════
unPaper · EDITORIAL · self-contained, print-ready 16:9 HTML · www.unpaper.ai
═══════════════════════════════════════════════════════════════════════════
A TEXT-FIRST, PUBLICATION design language — built like a printed journal, NOT
a slideware deck. Its structure is deliberately different from the Standard
template:
• a full-height LEFT MASTHEAD RAIL (running head + a big serif folio numeral),
divided from the main column by a hairline — not a top/bottom rail.
• article structure in the main column: a heavy rule, a small-caps kicker, a
large SERIF headline, a STANDFIRST, then SERIF body (not sans).
• signature devices: the drop cap, the crossing-rule 2×2 (one cross, not four
boxes), financial-page stat figures, full-bleed numeral plates, huge italic
pull quotes. The recurring motif is the RULE + the oversized serif numeral.
KEEP INTACT, NEVER CHANGE: the @page rule, the .slide 1280×720 sizing, and the
<script> at the bottom. They are the convertible skeleton shared with every
unPaper template. EDIT the slide content; reuse these layouts.
LOOK: options live on the <html> tag — data-theme (midnight·paper·slate·white),
data-bg (plain·grid·dots·gradient), data-accent. Running head / folio / brand
come from data-eyebrow / data-foot / data-ref / data-brand. Advanced chrome
options live on data-header / data-footer / data-footer-rule / data-page-numbers
and data-canvas (auto·white·custom; custom uses --custom-* vars) plus data-font.
═══════════════════════════════════════════════════════════════════════════
-->
<style>
/* ════════════════════════════════════════════════════════════════
SKIN TOKENS — palette · fonts · accent (shared token contract).
════════════════════════════════════════════════════════════════ */
:root{
--sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
--mono:ui-monospace,SFMono-Regular,"SF Mono","JetBrains Mono",Menlo,Consolas,monospace;
--label:var(--sans); /* letterspaced sans small-caps — magazine labels, not terminal mono */
}
html[data-font="modern"]{--sans:Aptos,"Segoe UI",system-ui,-apple-system,sans-serif;--serif:Georgia,"Times New Roman",serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;--label:var(--sans)}
html[data-font="classic"]{--sans:"Gill Sans","Trebuchet MS","Segoe UI",system-ui,sans-serif;--serif:Georgia,"Times New Roman",serif;--label:var(--sans)}
html[data-font="technical"]{--sans:"SF Pro Text","Segoe UI",system-ui,sans-serif;--serif:"Iowan Old Style",Georgia,serif;--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;--label:var(--mono)}
html[data-font="humanist"]{--sans:"Avenir Next","Trebuchet MS","Segoe UI",system-ui,sans-serif;--serif:"Iowan Old Style",Georgia,serif;--label:var(--sans)}
/* Each theme keeps a clear gap between --bg (the slide) and --bg-2 (the deck
page the slide floats on), so slides never blend into the background. */
html[data-theme="paper"]{ /* warm cream + oxblood */
--bg:#f6f3ec; --bg-2:#d8d0bf; --panel:#ffffff; --ink:#211e18; --ink-soft:#4d493f;
--ink-dim:#928c7d; --ink-faint:#bcb6a7; --line:rgba(33,30,24,.14); --line-soft:rgba(33,30,24,.06);
--accent:#7c2f33; --accent-ink:#ffffff; --grid:rgba(33,30,24,.035);
}
html[data-theme="slate"]{ /* cool stone-grey + slate blue */
--bg:#eef0f1; --bg-2:#c7ccd0; --panel:#ffffff; --ink:#1c2228; --ink-soft:#474e55;
--ink-dim:#828a91; --ink-faint:#aab0b6; --line:rgba(28,34,40,.15); --line-soft:rgba(28,34,40,.06);
--accent:#3d5a78; --accent-ink:#ffffff; --grid:rgba(28,34,40,.035);
}
html[data-theme="white"]{ /* crisp press: pure white + a single red, unwashed */
--bg:#ffffff; --bg-2:#d6d6d0; --panel:#ffffff; --ink:#15151a; --ink-soft:#43434b;
--ink-dim:#86868d; --ink-faint:#b6b6bc; --line:rgba(20,20,26,.16); --line-soft:rgba(20,20,26,.07);
--accent:#b3221b; --accent-ink:#ffffff; --grid:rgba(20,20,26,.04);
}
html[data-theme="midnight"]{ /* espresso slide on near-black + warm gold */
--bg:#211c16; --bg-2:#0a0907; --panel:#2a2419; --ink:#f5f0e6; --ink-soft:#d6cebd;
--ink-dim:#aaa28f; --ink-faint:#7e7765; --line:rgba(214,202,174,.28); --line-soft:rgba(214,202,174,.11);
--accent:#dca548; --accent-ink:#211c16; --grid:rgba(214,202,174,.05);
}
html[data-accent="gold"]{--accent:#b8860b;--accent-ink:#ffffff}
html[data-accent="teal"]{--accent:#0f7a6b;--accent-ink:#ffffff}
html[data-accent="blue"]{--accent:#1f4e79;--accent-ink:#ffffff}
html[data-accent="coral"]{--accent:#a8362a;--accent-ink:#ffffff}
html[data-accent="violet"]{--accent:#5b3f9c;--accent-ink:#ffffff}
html[data-theme="midnight"][data-accent="gold"]{--accent:#d8a24a;--accent-ink:#14110c}
html[data-theme="midnight"][data-accent="teal"]{--accent:#5cc2ad;--accent-ink:#14110c}
html[data-theme="midnight"][data-accent="blue"]{--accent:#7fb3ef;--accent-ink:#14110c}
html[data-theme="midnight"][data-accent="coral"]{--accent:#e8836f;--accent-ink:#14110c}
html[data-theme="midnight"][data-accent="violet"]{--accent:#b3a4f7;--accent-ink:#14110c}
/* ─── ADVANCED TEMPLATE SETTINGS (data-* on <html>) ───────────────── */
html[data-canvas="custom"]{
--bg:var(--custom-bg,#ffffff);
--bg-2:color-mix(in srgb,var(--custom-bg,#ffffff) 78%,#000 22%);
--panel:var(--custom-panel,#ffffff);
--ink:var(--custom-ink,#111827);
--ink-soft:var(--custom-ink-soft,#4b5563);
--ink-dim:color-mix(in srgb,var(--custom-ink-soft,#4b5563) 78%,transparent);
--ink-faint:color-mix(in srgb,var(--custom-ink-soft,#4b5563) 52%,transparent);
--line:var(--custom-line,#cbd5e1);
--line-soft:color-mix(in srgb,var(--custom-line,#cbd5e1) 45%,transparent);
--accent:var(--custom-accent,#2563a8);
--accent-ink:var(--custom-accent-ink,#ffffff);
--grid:color-mix(in srgb,var(--custom-line,#cbd5e1) 32%,transparent);
}
/* ════════════════════════════════════════════════════════════════
SKELETON — geometry, page flow, print/present, the engine script.
Shared verbatim with every unPaper template.
════════════════════════════════════════════════════════════════ */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg-2);color:var(--ink);font-family:var(--serif);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
aside.notes{display:none}
.deck{display:flex;flex-direction:column;align-items:center;gap:30px;padding:46px 24px 120px}
.slide-frame{width:min(100%,1280px);flex-shrink:0}
.slide{
width:1280px;height:720px;position:relative;overflow:hidden;
transform-origin:top left;
background:var(--bg);color:var(--ink);
box-shadow:0 24px 60px -42px rgba(0,0,0,.55);border-radius:0;
}
.slide::before{
content:"";position:absolute;inset:0;pointer-events:none;z-index:0;
background:radial-gradient(circle at 88% 6%, color-mix(in srgb, var(--accent) 5%, transparent), transparent 34%);
}
/* The white theme is PURE: no default glow. (Placed before the data-bg rules,
so an explicitly chosen grid/dots/gradient background still renders.) */
html[data-theme="white"] .slide::before{background:none}
html[data-bg="grid"] .slide::before{
background:
linear-gradient(var(--grid) 1px,transparent 1px),
linear-gradient(90deg,var(--grid) 1px,transparent 1px);
background-size:44px 44px,44px 44px;
}
html[data-bg="dots"] .slide::before{
background:radial-gradient(color-mix(in srgb, var(--ink-dim) 22%, transparent) 1.4px, transparent 1.5px);
background-size:30px 30px;
}
html[data-bg="gradient"] .slide::before{
background:linear-gradient(160deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent 52%);
}
html[data-canvas="custom"][data-bg="gradient"] .slide::before{
background:
radial-gradient(circle at 88% 6%, color-mix(in srgb,var(--custom-gradient-glow,var(--custom-accent,#2563a8)) 18%,transparent), transparent 36%),
linear-gradient(var(--custom-gradient-angle,160deg), color-mix(in srgb,var(--custom-gradient-from,var(--custom-bg,#ffffff)) 36%,transparent), transparent 54%),
linear-gradient(330deg, color-mix(in srgb,var(--custom-gradient-to,var(--custom-accent,#2563a8)) 20%,transparent), transparent 48%);
}
/* ════════════════════════════════════════════════════════════════
EDITORIAL LANGUAGE — masthead rail + article column.
════════════════════════════════════════════════════════════════ */
/* The page: a full-height LEFT RAIL divided from the MAIN column by a hairline. */
.pad{position:relative;z-index:1;height:100%;display:grid;grid-template-columns:190px 1fr;padding:52px 60px 48px}
.rail{display:flex;flex-direction:column;justify-content:space-between;
border-right:1px solid var(--line);padding-right:28px;min-height:0}
.rail .sec{font-family:var(--label);font-size:12px;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim);line-height:1.7}
.rail .sec b{display:block;color:var(--accent);font-weight:700}
.rail .foot{font-family:var(--label);font-size:11px;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-faint)}
.rail .folio{font-family:var(--serif);font-weight:400;font-size:60px;line-height:1;color:var(--ink);letter-spacing:-.02em}
.rail .folio small{display:block;font-family:var(--label);font-size:11px;letter-spacing:.16em;color:var(--ink-dim);margin-top:10px;font-weight:400}
.main{display:flex;flex-direction:column;min-height:0;padding-left:54px}
html[data-header="off"] .rail .sec,
html[data-header="off"] .runhead{display:none!important}
html[data-footer="off"] .rail .foot,
html[data-footer="off"] .cover-date{display:none!important}
html[data-footer-rule="off"] .rail{border-right-color:transparent!important}
html[data-footer-rule="off"] .runhead{border-bottom-color:transparent!important}
html[data-page-numbers="off"] .rail .folio,
html[data-page-numbers="off"] .pnum{display:none!important}
/* the auto-fit operates on this middle region */
.fit{flex:1;min-height:0;display:flex;flex-direction:column;justify-content:flex-start}
.fit-i{width:100%;margin:auto 0}
/* ── article furniture ─────────────────────────────────────────── */
.kicker{font-family:var(--label);font-size:12px;font-weight:600;letter-spacing:.22em;text-transform:uppercase;color:var(--accent);
padding-top:13px;border-top:1px solid var(--ink);display:block}
.headline{font-family:var(--serif);font-weight:400;font-size:50px;line-height:1.04;letter-spacing:-.018em;color:var(--ink);margin-top:16px;max-width:24ch}
.headline em{font-style:italic;color:var(--accent)}
.standfirst{font-family:var(--serif);font-weight:400;font-size:23px;line-height:1.5;color:var(--ink-soft);margin-top:18px;max-width:60ch}
.standfirst em{font-style:italic;color:var(--accent)}
.standfirst strong{font-weight:600;color:var(--ink)}
.body{font-family:var(--serif);font-size:19px;line-height:1.62;color:var(--ink-soft);max-width:64ch}
.body p{margin-top:14px}
.body p:first-child{margin-top:0}
.body em{font-style:italic;color:var(--accent)}
/* drop cap — an explicit element so it converts as its own native run */
.dropcap{float:left;font-family:var(--serif);font-weight:400;font-size:60px;line-height:.84;
padding:3px 12px 0 0;color:var(--accent)}
/* ── contents / numbered editorial list (big hanging serif numerals) ── */
.contents{display:flex;flex-direction:column}
.contents .entry{display:grid;grid-template-columns:96px 1fr;gap:28px;align-items:baseline;
padding:20px 0;border-top:1px solid var(--line)}
.contents .entry:last-child{border-bottom:1px solid var(--line)}
.contents .en{font-family:var(--serif);font-weight:400;font-size:46px;line-height:.9;color:var(--ink);text-align:right;letter-spacing:-.02em}
.contents h3{font-family:var(--serif);font-weight:500;font-size:25px;line-height:1.16;color:var(--ink)}
.contents p{font-family:var(--serif);font-size:17px;line-height:1.45;color:var(--ink-dim);margin-top:3px}
/* ── financial-page stat figures (no boxes; hairline + vertical rules) ── */
.figs{display:grid;grid-auto-flow:column;grid-auto-columns:1fr}
.fig{padding:0 30px}
.fig:first-child{padding-left:0}
.fig + .fig{border-left:1px solid var(--line)}
.fig .v{font-family:var(--serif);font-weight:400;font-size:80px;line-height:.92;color:var(--ink);letter-spacing:-.03em}
.fig .v sup{font-size:.42em;vertical-align:super;color:var(--accent)}
.fig .k{font-family:var(--label);font-size:12px;letter-spacing:.13em;text-transform:uppercase;color:var(--ink-dim);
margin-top:16px;padding-top:14px;border-top:1px solid var(--ink);line-height:1.5}
/* ── the big single number ─────────────────────────────────────── */
.bignum .v{font-family:var(--serif);font-weight:400;font-size:210px;line-height:.84;color:var(--ink);letter-spacing:-.04em}
.bignum .v em{font-style:italic;color:var(--accent)}
.bignum .k{font-family:var(--serif);font-size:24px;line-height:1.45;color:var(--ink-soft);max-width:34ch;margin-top:20px}
/* ── two serif columns split by a hairline (newspaper) ───────────── */
.columns{column-count:2;column-gap:46px;column-rule:1px solid var(--line);
font-family:var(--serif);font-size:18px;line-height:1.62;color:var(--ink-soft)}
.columns h4{font-family:var(--label);font-size:12px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
.columns p{margin-bottom:12px;break-inside:avoid}
/* ── crossing-rule 2×2 (ONE cross, not four boxes) ──────────────── */
.cross{position:relative;display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;height:404px;margin:46px 58px 44px}
.cross::before{content:"";position:absolute;left:0;right:0;top:50%;height:1px;background:var(--ink);transform:translateY(-50%)}
.cross::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;background:var(--ink);transform:translateX(-50%)}
/* content hugs each quadrant's OUTER corner so it never touches the central
cross (the overlap bug); the axes stay clear in the middle. */
.cq{padding:30px 40px;display:flex;flex-direction:column;gap:5px}
.cq.tl{align-items:flex-start;justify-content:flex-start;text-align:left}
.cq.tr{align-items:flex-end;justify-content:flex-start;text-align:right}
.cq.bl{align-items:flex-start;justify-content:flex-end;text-align:left}
.cq.br{align-items:flex-end;justify-content:flex-end;text-align:right}
.cq .ql{font-family:var(--label);font-size:11px;font-weight:700;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);margin-bottom:8px}
.cq h4{font-family:var(--serif);font-weight:500;font-size:24px;line-height:1.1;color:var(--ink);margin-bottom:6px}
.cq p{font-family:var(--serif);font-size:16px;line-height:1.4;color:var(--ink-dim);max-width:26ch}
.axlabel{position:absolute;font-family:var(--label);font-size:11px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;color:var(--ink-dim);white-space:nowrap;background:var(--bg);padding:0 8px}
.axlabel.top{left:50%;top:-26px;transform:translateX(-50%)}
.axlabel.bot{left:50%;bottom:-26px;transform:translateX(-50%)}
.axlabel.lft{left:0;top:50%;transform:translate(-150%,-50%);padding:6px 0;writing-mode:vertical-rl;rotate:180deg}
.axlabel.rgt{right:0;top:50%;transform:translate(150%,-50%);padding:6px 0;writing-mode:vertical-rl}
/* ── huge pull quote ───────────────────────────────────────────── */
.bigquote{font-family:var(--serif);font-weight:400;font-style:italic;font-size:50px;line-height:1.2;letter-spacing:-.012em;
color:var(--ink);max-width:24ch;padding-top:30px;border-top:1.5px solid var(--ink)}
.bigquote em{font-style:normal;color:var(--accent)}
.byline{font-family:var(--label);font-size:13px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim);margin-top:30px}
/* ── editorial table (heavy top/bottom rules; serif figures) ─────── */
.tbl{width:100%;border-collapse:collapse;font-family:var(--serif)}
.tbl caption{text-align:left;font-family:var(--label);font-size:12px;font-weight:600;letter-spacing:.18em;text-transform:uppercase;color:var(--accent);margin-bottom:14px}
.tbl th,.tbl td{text-align:left;padding:14px 22px;border-bottom:1px solid var(--line);font-size:18px;line-height:1.35;color:var(--ink-soft)}
.tbl thead th{font-family:var(--serif);font-weight:600;font-size:19px;color:var(--ink);border-bottom:1.5px solid var(--ink)}
.tbl tbody tr:last-child td{border-bottom:1.5px solid var(--ink)}
.tbl th:first-child,.tbl td:first-child{font-weight:600;color:var(--ink)}
.tbl .num{text-align:right;font-variant-numeric:tabular-nums}
/* ── chart (kept native via data-unpaper-chart) ─────────────────── */
.chart{max-width:760px}
.chart svg{width:100%;height:auto;display:block;overflow:visible}
.chart .clabel{font-family:var(--label);font-size:12px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px}
.chart .cbar{fill:var(--accent)}
.chart .cgrid{stroke:var(--line-soft)}
.chart .ctick{fill:var(--ink-dim);font-family:var(--label);font-size:13px}
/* ── layout grammar (shared contract) ───────────────────────────── */
[data-unpaper-lay]{display:grid;gap:42px;align-items:start;width:100%}
[data-unpaper-lay="2"]{grid-template-columns:1fr 1fr}
[data-unpaper-lay="2l"]{grid-template-columns:7fr 5fr}
[data-unpaper-lay="2r"]{grid-template-columns:5fr 7fr}
[data-unpaper-lay="3"]{grid-template-columns:repeat(3,1fr)}
[data-unpaper-lay="rail"]{grid-template-columns:4fr 8fr}
[data-unpaper-lay="quad"]{grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:16px}
[data-unpaper-lay] > *{min-width:0}
/* ════════════════════════════════════════════════════════════════
SPECIAL PAGES — cover & full-bleed plate drop the rail entirely.
════════════════════════════════════════════════════════════════ */
.cover .pad,.plate .pad{grid-template-columns:1fr;padding:56px 76px 52px}
.cover .main,.plate .main{padding-left:0}
/* full-bleed spread — data/table/chart pages drop the rail and run wide, with
the running head on a top line instead (a magazine data spread). */
.spread .pad{grid-template-columns:1fr;padding:50px 76px 46px}
.spread .rail{display:none}
.spread .main{padding-left:0}
.spread .runhead{display:flex;justify-content:space-between;align-items:baseline;
font-family:var(--label);font-size:11px;font-weight:600;letter-spacing:.2em;text-transform:uppercase;
color:var(--ink-dim);padding-bottom:14px;margin-bottom:6px;border-bottom:1px solid var(--line)}
.spread .runhead b{color:var(--accent)}
/* cover — a magazine cover */
.masthead{display:flex;justify-content:space-between;align-items:baseline;
padding-bottom:16px;border-bottom:1.5px solid var(--ink)}
.masthead .pub{font-family:var(--label);font-size:14px;font-weight:700;letter-spacing:.28em;text-transform:uppercase;color:var(--ink)}
.masthead .iss{font-family:var(--label);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim)}
.cover-title{font-family:var(--serif);font-weight:300;font-size:128px;line-height:.92;letter-spacing:-.035em;color:var(--ink);max-width:15ch}
.cover-title em{font-style:italic;color:var(--accent);font-weight:400}
.cover-stand{font-family:var(--serif);font-weight:400;font-size:26px;line-height:1.45;color:var(--ink-soft);max-width:54ch}
.cover-date{font-family:var(--label);font-size:13px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim)}
/* plate — full-bleed section divider built on a giant numeral */
.plate .pnum{font-family:var(--serif);font-weight:300;font-size:300px;line-height:.78;color:var(--accent);letter-spacing:-.04em}
.plate .ptitle{font-family:var(--serif);font-weight:400;font-size:64px;line-height:1.02;letter-spacing:-.02em;color:var(--ink);margin-top:6px;max-width:22ch}
.plate .psub{font-family:var(--label);font-size:13px;font-weight:700;letter-spacing:.2em;text-transform:uppercase;color:var(--ink-dim);margin-top:24px}
/* ─── floating toolbar (never prints) ──────────────────────────── */
.bar{position:fixed;z-index:60;left:50%;bottom:18px;transform:translateX(-50%);
display:flex;flex-wrap:nowrap;align-items:center;gap:3px;
max-width:calc(100vw - 16px);overflow-x:auto;scrollbar-width:none;padding:6px 7px;border-radius:999px;
background:rgba(20,17,12,.88);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.12)}
.bar::-webkit-scrollbar{display:none}
.bar > *{flex:0 0 auto}
.bar button{font-family:var(--label);font-size:12px;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;
color:#e9e2d4;background:transparent;border:0;border-radius:999px;padding:7px 11px;cursor:pointer;transition:background .15s}
.bar button:hover{background:rgba(255,255,255,.12)}
.bar button.on{background:var(--accent);color:var(--accent-ink);font-weight:700}
.bar .sep{width:1px;height:18px;background:rgba(255,255,255,.16)}
/* theme dots — each carries its theme's accent as a ring so the light shades
(paper · ink · slate · sepia) stay distinguishable; the active gets a halo. */
.bar .nav{font-size:16px;line-height:1;padding:4px 9px}
.bar .count{font-family:var(--label);font-size:11px;color:#b9b0a0;letter-spacing:.08em;min-width:40px;text-align:center}
/* collapsed (clean view): hide everything except the show/hide toggle */
.bar.min{padding:5px}
.bar.min > *:not(#bHide){display:none}
@media (max-width:480px){ .bar{gap:2px} .bar .sep{display:none} .bar button{padding:6px 9px} }
/* Touch devices: browser print letterboxes the fixed 16:9 page (iOS forces paper sizes),
so hide "Save PDF" and point to a computer. The unPaper app's PDF export stays mobile-safe. */
#bPrintHint{display:none}
@media (hover:none) and (pointer:coarse){ #bPrint{display:none} #bPrintHint{display:inline;font-family:ui-monospace,monospace;font-size:11px;letter-spacing:.04em;color:#9fb0c4;white-space:nowrap;padding:0 6px} }
@media (max-width:640px){ #bPrint{display:none} }
.np{display:none}
body.present .bar .np{display:inline-flex;align-items:center}
body.present .bar span.np{display:inline-block}
/* ─── PRESENT MODE ─────────────────────────────────────────────── */
body.present{overflow:hidden}
/* speaker-notes overlay for present mode (E6.2) — inert unless body.present.notes-on, so scripts-off/print/convert never see it */
.notesbar{display:none}
/* in-window present nav (buttons exist only when the script builds them) */
.pnav{display:none}
body.present .pnav{display:flex;position:fixed;top:50%;transform:translateY(-50%);z-index:60;width:44px;height:44px;align-items:center;justify-content:center;border:0;border-radius:999px;background:rgba(17,19,25,.5);color:#fff;font:400 26px/1 ui-sans-serif,system-ui,sans-serif;cursor:pointer;opacity:.4;transition:opacity .15s}
body.present .pnav:hover{opacity:.95}
.pnav-l{left:14px}.pnav-r{right:14px}
body.present.notes-on .notesbar{display:block;position:fixed;left:0;right:0;bottom:0;z-index:70;max-height:36vh;overflow:auto;padding:16px 26px;background:#14161c;color:#fff;font:400 16px/1.55 ui-sans-serif,system-ui,sans-serif;box-shadow:0 -8px 30px rgba(0,0,0,.4)}
.notesbar b{display:block;font:700 11px/1 ui-monospace,monospace;letter-spacing:.18em;text-transform:uppercase;opacity:.55;margin-bottom:7px}
.notesbar .empty{opacity:.5;font-style:italic}
body.present .deck{padding:0;gap:0}
body.present .slide-frame{display:none}
body.present .slide-frame.live{display:flex;position:fixed;top:0;left:0;width:100%;height:100dvh;
align-items:center;justify-content:center;overflow:hidden;z-index:40;background:var(--bg-2)}
body.present .slide-frame.live .slide{flex:0 0 auto}
/* ─── PRINT – every slide is exactly one 16:9 page ─────────────── */
@page{size:338.67mm 190.5mm;margin:0}
@media print{
html,body{background:#fff}
.deck{display:block;padding:0;gap:0}
.slide-frame{width:auto;display:block!important}
.slide{transform:none!important;width:1280px;height:720px;box-shadow:none;border-radius:0;
break-after:page;page-break-after:always}
.slide-frame:last-child .slide{break-after:auto;page-break-after:auto}
.bar{display:none!important}
*{-webkit-print-color-adjust:exact!important;print-color-adjust:exact!important}
}
/* E5.2 running brand logo: painted once from --brand-logo, shown when data-logo=on (replaces the masthead brand) */
html[data-logo="on"] .slide::after{content:"";position:absolute;top:46px;right:60px;width:150px;height:24px;background:var(--brand-logo) no-repeat right center;background-size:contain;z-index:6;pointer-events:none}
html[data-logo="on"] .t-brand{display:none}
</style>
</head>
<body>
<div class="deck" id="deck">
<!-- ═══ COVER · magazine cover, full bleed, no rail ═══ -->
<div class="slide-frame"><section class="slide cover" data-kind="cover" data-starter>
<div class="pad">
<main class="main">
<div class="masthead">
<span class="pub t-brand"></span>
<span class="iss t-ref"></span>
</div>
<div class="fit"><div class="fit-i">
<h1 class="cover-title">Working <em>Hours</em></h1>
<p class="cover-stand" style="margin-top:34px">A field report on how the workday came apart, and the quiet new shape it is settling into.</p>
</div></div>
<div class="cover-date t-foot"></div>
</main>
</div>
<aside class="notes">Welcome to the report. Working Hours is a field study of how the workday came apart and what is settling in its place.</aside>
</section></div>
<!-- ═══ CONTENTS · big hanging serif numerals ═══ -->
<div class="slide-frame"><section class="slide" data-kind="contents" data-tier="1">
<div class="pad">
<aside class="rail">
<div class="sec"><b>Contents</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">02<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">In this issue</span>
<div class="contents" style="margin-top:26px">
<div class="entry"><div class="en">01</div><div><h3>The empty Tuesday</h3><p>Attendance stopped following the calendar.</p></div></div>
<div class="entry"><div class="en">02</div><div><h3>Two forces, one week</h3><p>Why presence and focus keep pulling apart.</p></div></div>
<div class="entry"><div class="en">03</div><div><h3>The numbers, plainly</h3><p>Occupancy, days in, and what people say they want.</p></div></div>
<div class="entry"><div class="en">04</div><div><h3>Four offices in one</h3><p>The archetypes a single floor now has to hold.</p></div></div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ PLATE · full-bleed section divider on a giant numeral ═══ -->
<div class="slide-frame"><section class="slide plate" data-kind="divider" data-tier="2">
<div class="pad">
<main class="main">
<div class="fit"><div class="fit-i">
<div class="pnum">01</div>
<h2 class="ptitle">The empty Tuesday</h2>
<div class="psub">Part one · The week loses its centre</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ FEATURE · drop cap, standfirst, serif body ═══ -->
<div class="slide-frame"><section class="slide" data-kind="feature" data-tier="1">
<div class="pad">
<aside class="rail">
<div class="sec">The empty Tuesday<b>01</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">04<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Attendance</span>
<h2 class="headline">The office no longer fills on a <em>schedule</em></h2>
<div class="body" style="margin-top:34px">
<p><span class="dropcap">F</span>or a century the workday had a rhythm you could set a clock by: five mornings in, five evenings out. That rhythm is gone. Badge data now shows a building that breathes unevenly, swelling mid-week and emptying at the edges.</p>
<p>The change is not less work. It is work that has come loose from <em>place and hour</em>, and a calendar that used to organise everyone at once now organises no one in particular.</p>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ TWO COLUMNS · newspaper, hairline rule between ═══ -->
<div class="slide-frame"><section class="slide" data-kind="split" data-tier="1">
<div class="pad">
<aside class="rail">
<div class="sec">Two forces<b>02</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">05<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">The tension</span>
<h2 class="headline" style="font-size:40px;margin-bottom:26px">Two forces are pulling the week apart</h2>
<div class="columns">
<h4>Presence</h4>
<p>Teams still need the unplanned hour: the corridor question, the whiteboard nobody booked, the new hire who learns by overhearing. None of it survives a calendar.</p>
<p>So presence clusters. Tuesday and Wednesday now carry the social load the whole week used to share.</p>
<h4 style="margin-top:6px">Focus</h4>
<p>The deep work that presence interrupts has fled the other way, into the quiet days at home where the meetings are not.</p>
<p>The office did not lose its purpose. It split into two purposes that no longer fit the same day.</p>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ FIGURES · financial-page stats, no boxes ═══ -->
<div class="slide-frame"><section class="slide" data-kind="stats" data-tier="2">
<div class="pad">
<aside class="rail">
<div class="sec">The numbers<b>03</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">06<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Occupancy, plainly</span>
<h2 class="headline" style="font-size:38px;margin-bottom:40px">What a year of badge data actually shows</h2>
<div class="figs">
<div class="fig"><div class="v">47<sup>%</sup></div><div class="k">Average desk occupancy, down from 64% in 2019</div></div>
<div class="fig"><div class="v">2.4</div><div class="k">Median days a week in the office</div></div>
<div class="fig"><div class="v">71<sup>%</sup></div><div class="k">Of staff want to keep a hybrid week</div></div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ CHART · native via data-unpaper-chart ═══ -->
<div class="slide-frame"><section class="slide" data-kind="chart" data-tier="2">
<div class="pad">
<aside class="rail">
<div class="sec">The numbers<b>03</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">07<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Attendance by day</span>
<h2 class="headline" style="font-size:38px;margin-bottom:8px">The week now peaks in the middle</h2>
<p class="standfirst" style="font-size:19px;margin-bottom:24px">Share of staff in the building, averaged across the quarter.</p>
<div class="chart" data-unpaper-chart='{"type":"bar","categories":["Mon","Tue","Wed","Thu","Fri"],"series":[{"name":"In office","values":[38,72,78,61,24],"color":"8A2B22"}],"axis":{"min":0,"max":100}}'>
<svg viewBox="0 0 760 300" role="img" aria-label="Share in office by weekday">
<line class="cgrid" x1="0" y1="252" x2="760" y2="252" stroke-width="1.5"/>
<rect class="cbar" x="20" y="156" width="108" height="96" rx="0"/>
<rect class="cbar" x="168" y="70" width="108" height="182" rx="0"/>
<rect class="cbar" x="316" y="55" width="108" height="197" rx="0"/>
<rect class="cbar" x="464" y="98" width="108" height="154" rx="0"/>
<rect class="cbar" x="612" y="191" width="108" height="61" rx="0"/>
<text class="ctick" x="74" y="278" text-anchor="middle">Mon</text>
<text class="ctick" x="222" y="278" text-anchor="middle">Tue</text>
<text class="ctick" x="370" y="278" text-anchor="middle">Wed</text>
<text class="ctick" x="518" y="278" text-anchor="middle">Thu</text>
<text class="ctick" x="666" y="278" text-anchor="middle">Fri</text>
</svg>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ TABLE · full-bleed data spread (no rail, runs wide) ═══ -->
<div class="slide-frame"><section class="slide spread" data-kind="table" data-tier="3">
<div class="pad">
<main class="main">
<div class="runhead"><span>The numbers <b>03</b></span><span>08 &middot; <span class="t-ref"></span></span></div>
<div class="fit"><div class="fit-i">
<span class="kicker">Policy in practice</span>
<h2 class="headline" style="font-size:38px;margin-bottom:24px">Three answers to the same empty floor</h2>
<table class="tbl">
<caption>How firms set the week, 2026</caption>
<thead><tr><th>Approach</th><th>Days set</th><th class="num">Occupancy</th><th>Trade-off</th></tr></thead>
<tbody>
<tr><td>Anchor days</td><td>Tue, Wed, Thu</td><td class="num">68%</td><td>Crowded midweek, dead edges</td></tr>
<tr><td>Team's choice</td><td>Set by manager</td><td class="num">44%</td><td>Flexible, harder to plan</td></tr>
<tr><td>Fully open</td><td>None</td><td class="num">29%</td><td>Quiet, but the room goes unused</td></tr>
</tbody>
</table>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ CROSS · 2×2 as one crossing rule, not four boxes ═══ -->
<div class="slide-frame"><section class="slide" data-kind="framework" data-tier="3">
<div class="pad">
<aside class="rail">
<div class="sec">Four offices<b>04</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">09<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Archetypes</span>
<h2 class="headline" style="font-size:34px;margin-bottom:6px">Four offices the same floor must now hold</h2>
<div class="cross">
<div class="cq tl"><div class="ql">High focus · Low contact</div><h4>The library</h4><p>Heads-down work that fled the meeting days.</p></div>
<div class="cq tr"><div class="ql">High focus · High contact</div><h4>The studio</h4><p>Makers who need both quiet and a quick huddle.</p></div>
<div class="cq bl"><div class="ql">Low focus · Low contact</div><h4>The waystation</h4><p>A desk between trains; barely used, rarely missed.</p></div>
<div class="cq br"><div class="ql">Low focus · High contact</div><h4>The forum</h4><p>The reason the building still exists at all.</p></div>
<div class="axlabel top">More focus &uarr;</div>
<div class="axlabel bot">Less focus</div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ LINE CHART · a trend over weeks ═══ -->
<div class="slide-frame"><section class="slide" data-kind="chart-line" data-tier="2">
<div class="pad">
<aside class="rail">
<div class="sec">The numbers<b>03</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">10<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Week by week</span>
<h2 class="headline" style="font-size:38px;margin-bottom:8px">The middle of the week pulled away from the edges</h2>
<p class="standfirst" style="font-size:19px;margin-bottom:24px">Average attendance, first five weeks of the quarter.</p>
<div class="chart" data-unpaper-chart='{"type":"line","categories":["W1","W2","W3","W4","W5"],"series":[{"name":"Midweek","values":[58,66,72,75,78],"color":"8A2B22"},{"name":"Edge days","values":[40,36,32,29,26],"color":"B08A3E"}],"axis":{"min":0,"max":100}}'>
<svg viewBox="0 0 760 300" role="img" aria-label="Midweek versus edge-day attendance by week">
<line class="cgrid" x1="0" y1="252" x2="760" y2="252" stroke-width="1.5"/>
<polyline fill="none" stroke="#8A2B22" stroke-width="3" points="74,105 222,85 370,70 518,62 666,55"/>
<polyline fill="none" stroke="#B08A3E" stroke-width="3" points="74,151 222,161 370,171 518,179 666,186"/>
<text class="ctick" x="74" y="278" text-anchor="middle">W1</text>
<text class="ctick" x="222" y="278" text-anchor="middle">W2</text>
<text class="ctick" x="370" y="278" text-anchor="middle">W3</text>
<text class="ctick" x="518" y="278" text-anchor="middle">W4</text>
<text class="ctick" x="666" y="278" text-anchor="middle">W5</text>
</svg>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ DOUGHNUT · share of a whole ═══ -->
<div class="slide-frame"><section class="slide" data-kind="chart-pie" data-tier="3">
<div class="pad">
<aside class="rail">
<div class="sec">The numbers<b>03</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">11<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">How the week is spent</span>
<h2 class="headline" style="font-size:38px;margin-bottom:28px">Three ways of working, one workforce</h2>
<div style="display:flex;align-items:center;gap:54px">
<div class="chart" data-unpaper-chart='{"type":"doughnut","categories":["In office","Hybrid","Remote"],"series":[{"name":"Share","values":[47,33,20]}],"colors":["8A2B22","B08A3E","6B6256"]}' style="flex:none;width:220px">
<svg viewBox="0 0 42 42" role="img" aria-label="Share by working pattern">
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#6B6256" stroke-width="6"/>
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#B08A3E" stroke-width="6" stroke-dasharray="33 67" stroke-dashoffset="-47" transform="rotate(-90 21 21)"/>
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#8A2B22" stroke-width="6" stroke-dasharray="47 53" transform="rotate(-90 21 21)"/>
</svg>
</div>
<div style="display:flex;flex-direction:column;gap:16px;font-size:20px">
<span style="display:flex;align-items:center;gap:13px"><span style="width:14px;height:14px;background:#8A2B22;display:inline-block"></span>In office &middot; 47%</span>
<span style="display:flex;align-items:center;gap:13px"><span style="width:14px;height:14px;background:#B08A3E;display:inline-block"></span>Hybrid &middot; 33%</span>
<span style="display:flex;align-items:center;gap:13px"><span style="width:14px;height:14px;background:#6B6256;display:inline-block"></span>Remote &middot; 20%</span>
</div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ SMALL MULTIPLES · full-bleed data spread, one scale ═══ -->
<div class="slide-frame"><section class="slide spread" data-kind="chart-multi" data-tier="3">
<div class="pad">
<main class="main">
<div class="runhead"><span>The numbers <b>03</b></span><span>12 &middot; <span class="t-ref"></span></span></div>
<div class="fit"><div class="fit-i">
<span class="kicker">By floor, on one scale</span>
<h2 class="headline" style="font-size:36px;margin-bottom:22px">The same week reads differently floor by floor</h2>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:48px">
<div>
<div class="kicker" style="margin-bottom:8px">Engineering</div>
<div class="chart" data-unpaper-chart='{"type":"bar","categories":["Mon","Wed","Fri"],"series":[{"name":"Engineering","values":[30,74,22],"color":"8A2B22"}],"axis":{"min":0,"max":100}}'>
<svg viewBox="0 0 300 200"><line class="cgrid" x1="0" y1="170" x2="300" y2="170" stroke-width="1.5"/><rect class="cbar" x="24" y="119" width="64" height="51" rx="0"/><rect class="cbar" x="118" y="44" width="64" height="126" rx="0"/><rect class="cbar" x="212" y="133" width="64" height="37" rx="0"/></svg>
</div>
</div>
<div>
<div class="kicker" style="margin-bottom:8px">Sales</div>
<div class="chart" data-unpaper-chart='{"type":"bar","categories":["Mon","Wed","Fri"],"series":[{"name":"Sales","values":[64,70,58],"color":"8A2B22"}],"axis":{"min":0,"max":100}}'>
<svg viewBox="0 0 300 200"><line class="cgrid" x1="0" y1="170" x2="300" y2="170" stroke-width="1.5"/><rect class="cbar" x="24" y="61" width="64" height="109" rx="0"/><rect class="cbar" x="118" y="51" width="64" height="119" rx="0"/><rect class="cbar" x="212" y="71" width="64" height="99" rx="0"/></svg>
</div>
</div>
<div>
<div class="kicker" style="margin-bottom:8px">Design</div>
<div class="chart" data-unpaper-chart='{"type":"bar","categories":["Mon","Wed","Fri"],"series":[{"name":"Design","values":[20,52,40],"color":"8A2B22"}],"axis":{"min":0,"max":100}}'>
<svg viewBox="0 0 300 200"><line class="cgrid" x1="0" y1="170" x2="300" y2="170" stroke-width="1.5"/><rect class="cbar" x="24" y="136" width="64" height="34" rx="0"/><rect class="cbar" x="118" y="82" width="64" height="88" rx="0"/><rect class="cbar" x="212" y="102" width="64" height="68" rx="0"/></svg>
</div>
</div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ FIGURE PLATE · one number, full bleed ═══ -->
<div class="slide-frame"><section class="slide plate" data-kind="bigfigure" data-tier="1">
<div class="pad">
<main class="main">
<div class="fit"><div class="fit-i">
<div class="pnum">47%</div>
<h2 class="ptitle">of desks sit empty on an average day</h2>
<div class="psub">Down from 64% before the week lost its centre</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ CHRONOLOGY · a dated sequence ═══ -->
<div class="slide-frame"><section class="slide" data-kind="chronology" data-tier="2">
<div class="pad">
<aside class="rail">
<div class="sec">How we got here<b>05</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">14<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">A short chronology</span>
<h2 class="headline" style="font-size:38px;margin-bottom:30px">How a fixed week came undone</h2>
<div style="display:flex;flex-direction:column;gap:24px">
<div style="display:grid;grid-template-columns:120px 1fr;gap:28px;align-items:baseline"><span class="kicker" style="font-size:20px">2019</span><p style="font-size:20px;line-height:1.5;color:var(--ink-soft,inherit)">Five days in was simply the week. No one thought to measure it.</p></div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:28px;align-items:baseline"><span class="kicker" style="font-size:20px">2020</span><p style="font-size:20px;line-height:1.5;color:var(--ink-soft,inherit)">The building emptied overnight, and the work carried on anyway.</p></div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:28px;align-items:baseline"><span class="kicker" style="font-size:20px">2022</span><p style="font-size:20px;line-height:1.5;color:var(--ink-soft,inherit)">Hybrid policies arrived, written for a week that no longer existed.</p></div>
<div style="display:grid;grid-template-columns:120px 1fr;gap:28px;align-items:baseline"><span class="kicker" style="font-size:20px">2026</span><p style="font-size:20px;line-height:1.5;color:var(--ink-soft,inherit)">Attendance follows the work, not the calendar — and the floor must keep up.</p></div>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ PLATE · second chapter divider ═══ -->
<div class="slide-frame"><section class="slide plate" data-kind="divider2" data-tier="2">
<div class="pad">
<main class="main">
<div class="fit"><div class="fit-i">
<div class="pnum">05</div>
<h2 class="ptitle">What a floor must now hold</h2>
<div class="psub">Part two · Designing for four offices at once</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ FEATURE · second essay, drop cap ═══ -->
<div class="slide-frame"><section class="slide" data-kind="feature2" data-tier="1">
<div class="pad">
<aside class="rail">
<div class="sec">What a floor holds<b>05</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">16<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">The redesign</span>
<h2 class="headline">A single floor, asked to be <em>four rooms</em></h2>
<div class="body" style="margin-top:34px">
<p><span class="dropcap">T</span>he answer is not more desks or fewer. It is a floor that can be quiet and loud in the same hour: a library for the deep days, a forum for the crowded ones, and the grace to switch between them without a memo.</p>
<p>The firms getting this right stopped counting heads and started counting <em>purposes</em>, then built a room for each. Attendance, oddly, recovered once the building stopped pretending the week was still flat.</p>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ ENDNOTE · notes on method ═══ -->
<div class="slide-frame"><section class="slide" data-kind="methodology" data-tier="3">
<div class="pad">
<aside class="rail">
<div class="sec">Endnotes<b>—</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">17<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Notes on method</span>
<h2 class="headline" style="font-size:36px;margin-bottom:24px">How this was put together</h2>
<div class="body">
<p>Figures draw on anonymised badge-entry counts across twelve buildings over four quarters, normalised to each floor's pre-2020 baseline. Attitudes come from a survey of 1,400 staff in the same firms.</p>
<p style="font-size:15px;margin-top:18px;opacity:.8">Sources: building access logs (2025–26); internal hybrid-work survey, Q1 2026. Illustrative figures throughout.</p>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ PULL QUOTE · huge italic display ═══ -->
<div class="slide-frame"><section class="slide" data-kind="quote" data-tier="2">
<div class="pad">
<aside class="rail">
<div class="sec">In a line<b>—</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">18<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<p class="bigquote">The office did not empty. It <em>specialised</em>, and the calendar was the last to find out.</p>
<p class="byline">&mdash; Field notes, Q1</p>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ FREE CANVAS · blank page, set in this type ═══ -->
<div class="slide-frame"><section class="slide" data-kind="canvas" data-tier="3">
<div class="pad">
<aside class="rail">
<div class="sec">Open page<b>—</b></div>
<div>
<div class="foot t-foot" style="margin-bottom:18px"></div>
<div class="folio">19<small class="t-ref"></small></div>
</div>
</aside>
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker">Free canvas</span>
<h2 class="headline" style="font-size:34px;margin-bottom:24px">A blank page, set in this type</h2>
<!-- FREE CANVAS: keep the masthead rail and folio; compose any layout in this column using convertible primitives. -->
<div style="border:1.5px dashed var(--line);min-height:300px;display:flex;align-items:center;justify-content:center;text-align:center;padding:40px">
<p style="font-size:19px;line-height:1.5;color:var(--ink-dim);max-width:46ch">Keep the masthead rail and folio; compose any layout you like in this column — serif text, a hairline rule, an inline SVG, a table or a chart all convert to native, editable objects.</p>
</div>
</div></div>
</main>
</div>
</section></div>
<!-- ═══ CLOSING · colophon ═══ -->
<div class="slide-frame"><section class="slide plate" data-kind="closing" data-starter>
<div class="pad">
<main class="main">
<div class="fit"><div class="fit-i">
<span class="kicker" style="display:inline-block">End of report</span>
<h2 class="ptitle" style="font-size:56px;margin-top:18px">Replace this with <em style="font-style:italic;color:var(--accent)">your</em> story, then print it.</h2>
<p class="cover-stand" style="margin-top:26px">Set in unPaper Editorial &middot; <strong>www.unpaper.ai</strong></p>
</div></div>
</main>
</div>
<aside class="notes">This closing plate drops the rail like the cover and dividers. Swap the headline for your own call to action, keep one accent, and let the whitespace carry it.</aside>
</section></div>
</div>
<!-- toolbar – remove this whole block if you don't want it. It never prints. -->
<div class="bar" id="bar">
<button id="mScroll" class="on">Scroll</button>
<button id="mPresent">Present</button>
<span class="sep np"></span>
<button id="prev" class="nav np" title="Previous (←)">‹</button>
<span id="count" class="count np">1 / 11</span>
<button id="next" class="nav np" title="Next (→)">›</button>
<span class="sep"></span>
<button id="bPrint">Save PDF</button>
<span id="bPrintHint">Save a PDF on a computer</span>
<button id="bHide" class="nav" title="Hide controls" aria-label="Hide controls">▾</button>
</div>
<script>
/* unPaper slide engine – header/footer fill, scaling, auto-fit, present mode. Do not remove. */
(function(){
var DESIGN_W=1280, DESIGN_H=720, FLOOR=0.4;
var frames=[].slice.call(document.querySelectorAll('.slide-frame'));
function vv(){var v=window.visualViewport;return v?{w:v.width,h:v.height}:{w:window.innerWidth,h:window.innerHeight};}
function barReserve(){var b=document.getElementById('bar');return b?b.offsetHeight+30:80;}
function fillMeta(){
var d=document.documentElement.dataset;
document.querySelectorAll('.t-eye').forEach(function(e){e.textContent=d.eyebrow||''});
document.querySelectorAll('.t-brand').forEach(function(e){e.textContent=d.brand||''});
document.querySelectorAll('.t-foot').forEach(function(e){e.textContent=d.foot||''});
document.querySelectorAll('.t-ref').forEach(function(e){e.textContent=d.ref||''});
numberPages();
}
/* folio numerals come from the slide's real position, not from whatever the
AI typed – so a model that numbers content from 1 can't drift. Plate/cover
slides carry no .folio (they use the decorative .pnum, left untouched), so
they stay unnumbered. data-page-numbers="off" still hides folios via CSS. */
function numberPages(){
var width=String(frames.length).length;
function pad(n){var s=''+n;while(s.length<width)s='0'+s;return s;}
frames.forEach(function(f,i){
var folio=f.querySelector('.folio'); if(!folio)return;
var pos=pad(i+1);
/* replace only the leading text node so the <small class="t-ref"> survives */
if(folio.firstChild&&folio.firstChild.nodeType===3)folio.firstChild.nodeValue=pos;
else folio.insertBefore(document.createTextNode(pos),folio.firstChild);
});
}
function scaleToFrames(){
var present=document.body.classList.contains('present');
var v=vv(), reserve=present?barReserve():0;
frames.forEach(function(f){
var slide=f.querySelector('.slide'), s;
if(present){
s=Math.min((v.w-24)/DESIGN_W, (v.h-reserve-16)/DESIGN_H);
if(!(s>0))s=0.1;
slide.style.transformOrigin='center center';
slide.style.transform='scale('+s+')';
f.style.paddingTop='8px';f.style.paddingBottom=reserve+'px';f.style.height='';
}else{
s=f.clientWidth/DESIGN_W;
slide.style.transformOrigin='top left';
slide.style.transform='scale('+s+')';
f.style.paddingTop='';f.style.paddingBottom='';f.style.height=(DESIGN_H*s)+'px';
}
});
}
function autofit(){
frames.forEach(function(f){
var slide=f.querySelector('.slide');
slide.querySelectorAll('.fit').forEach(function(fit){
var inner=fit.querySelector('.fit-i'); if(!inner)return;
inner.style.transform='';inner.style.width='';
var availH=fit.clientHeight, needH=inner.scrollHeight;
var availW=fit.clientWidth, needW=inner.scrollWidth, s=1;
var sh=needH>availH+1?availH/needH:1, sw=needW>availW+1?availW/needW:1;
if(sh<1||sw<1){ s=Math.max(FLOOR, Math.min(sh,sw)); /* shrink content to fully fit – never clip, height AND width */
inner.style.transformOrigin='top left';
inner.style.transform='scale('+s+')';
inner.style.width=(100/s)+'%';
}
if(s<0.86) slide.setAttribute('data-overfull',''); else slide.removeAttribute('data-overfull');
});
});
}
function refit(){fillMeta();autofit();scaleToFrames();}
var cur=0;
function show(i){
cur=Math.max(0,Math.min(frames.length-1,i));
frames.forEach(function(f,k){f.classList.toggle('live',k===cur)});
var c=document.getElementById('count');if(c)c.textContent=(cur+1)+' / '+frames.length;
scaleToFrames();
}
function setMode(present){
document.body.classList.toggle('present',present);
document.getElementById('mPresent').classList.toggle('on',present);
document.getElementById('mScroll').classList.toggle('on',!present);
if(present)show(cur); else frames.forEach(function(f){f.classList.remove('live')});
refit();
}
document.getElementById('mPresent').onclick=function(){setMode(true)};
document.getElementById('mScroll').onclick=function(){setMode(false)};
document.getElementById('prev').onclick=function(){show(cur-1)};
document.getElementById('next').onclick=function(){show(cur+1)};
document.getElementById('bPrint').onclick=function(){var p=document.body.classList.contains('present');setMode(false);setTimeout(function(){window.print();if(p)setMode(true)},80)};
/* hide / show the toolbar for a clean, control-free view */
var bHide=document.getElementById('bHide');
if(bHide)bHide.onclick=function(){
var min=document.getElementById('bar').classList.toggle('min');
bHide.textContent=min?'▴':'▾';
bHide.title=min?'Show controls':'Hide controls';
};
/* present-mode in-window nav arrows + speaker-notes overlay (E6.2, revised: present stays inside the window — no fullscreen request). Progressive enhancement: no JS = no present mode, so the converter/print never see any of this. */
var pnavL=document.createElement('button');pnavL.className='pnav pnav-l';pnavL.textContent='\u2039';pnavL.title='Previous (\u2190)';pnavL.setAttribute('aria-label','Previous slide');document.body.appendChild(pnavL);
var pnavR=document.createElement('button');pnavR.className='pnav pnav-r';pnavR.textContent='\u203a';pnavR.title='Next (\u2192)';pnavR.setAttribute('aria-label','Next slide');document.body.appendChild(pnavR);
pnavL.onclick=function(){show(cur-1)};pnavR.onclick=function(){show(cur+1)};
var notesbar=document.createElement('div');notesbar.className='notesbar';document.body.appendChild(notesbar);
function renderNotes(){var f=frames[cur],n=f&&f.querySelector('.notes');notesbar.textContent='';var h=document.createElement('b');h.textContent='Speaker notes \u00b7 '+(cur+1)+' / '+frames.length;notesbar.appendChild(h);var d=document.createElement('div'),t=n&&n.textContent?n.textContent.trim():'';if(t){d.textContent=t;}else{d.textContent='No notes on this slide.';d.className='empty';}notesbar.appendChild(d);}
function toggleNotes(){if(!document.body.classList.contains('present'))return;document.body.classList.toggle('notes-on');if(document.body.classList.contains('notes-on'))renderNotes();}
var _setMode=setMode;setMode=function(p){_setMode(p);if(!p)document.body.classList.remove('notes-on');};
var _show=show;show=function(i){_show(i);if(document.body.classList.contains('notes-on'))renderNotes();};
window.addEventListener('keydown',function(e){
if(!document.body.classList.contains('present'))return;
if(e.key==='ArrowRight'||e.key==='ArrowDown'||e.key===' ')show(cur+1);
if(e.key==='ArrowLeft'||e.key==='ArrowUp')show(cur-1);
if(e.key==='Escape')setMode(false);
if(e.key==='n'||e.key==='N')toggleNotes();
});
var tx=0,ty=0;
window.addEventListener('touchstart',function(e){var t=e.changedTouches[0];tx=t.clientX;ty=t.clientY;},{passive:true});
window.addEventListener('touchend',function(e){
if(!document.body.classList.contains('present'))return;
var t=e.changedTouches[0],dx=t.clientX-tx,dy=t.clientY-ty;
if(Math.abs(dx)>48&&Math.abs(dx)>Math.abs(dy))show(dx<0?cur+1:cur-1);
},{passive:true});
window.addEventListener('resize',refit);
window.addEventListener('orientationchange',function(){setTimeout(refit,180)});
if(window.visualViewport){window.visualViewport.addEventListener('resize',refit);window.visualViewport.addEventListener('scroll',refit);}
window.addEventListener('load',refit);
if(document.fonts&&document.fonts.ready)document.fonts.ready.then(refit);
/* Decks OPEN PRESENTING by default — but only for a person in a real browser:
top-level window, not automation (headless converters set navigator.webdriver),
so iframes/embeds and the html→pptx pipeline keep the scroll layout they
measure. Open …#scroll to start in the scroll view; …#present forces present
even inside an iframe. */
if(location.hash==='#scroll'){refit();}
else if(location.hash==='#present'||(self===top&&!navigator.webdriver)){setMode(true);}
else{refit();}
})();
</script>
</body>
</html>

Sorry, the diff of this file is too big to display

+166
-12
#!/usr/bin/env node
// unPaper CLI — placeholder. Reserves the `unpaper` name on npm while the real
// command-line tool is on the roadmap. For now it just points to the web app.
const b = "\x1b[1m"
const dim = "\x1b[2m"
const r = "\x1b[0m"
/**
* @unpaper/cli — unPaper on your machine. Local, offline, no AI inside.
* A product of reHeritage GmbH · https://www.unpaper.ai
*
* unpaper convert <deck.html> [-o out.pptx] [--selector .slide] [--no-shapes] [--browser <path>] [--json]
* unpaper check <deck.html> [--json] exit 0 = L1+ and no errors (CI-usable)
* unpaper check <tpl.html> --template [--family <f>] [-o repaired.html] byte-law validation (T6)
* unpaper starter [--family standard|consulting|design|editorial] [-o file.html]
* unpaper templatize [--family <f>] [-o payload.txt] company re-skin payload
* unpaper templatize --name "Acme" [--colors "#0e2841,#f5c518"] [--personality technical]
* [--logo logo.svg] [--present proposals,research] [--founded start.html]
* unpaper patterns [family|ID]
* unpaper import <deck.pptx|ppt|odp> [--emit-verbatim] [--emit-brand-kit]
* unpaper rehydrate <redesign.html> <deck.images.json> -o final.html
* unpaper mcp stdio MCP server (Claude Code/Desktop, Cursor)
*/
import { writeFileSync } from 'node:fs'
import { VERSION } from '../dist/version.mjs'
console.log(`
${b}unPaper${r} — ask any AI for print-ready HTML, get an editable PowerPoint.
const argv = process.argv.slice(2)
const cmd = argv.shift()
The command-line tool is coming. Today unPaper runs in your browser,
fully client-side, with nothing uploaded:
const flags = {}
const pos = []
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (a === '-o' || a === '--out') flags.out = argv[++i]
else if (a === '--family') flags.family = argv[++i]
else if (a === '--selector') flags.selector = argv[++i]
else if (a === '--browser') flags.browser = argv[++i]
else if (a === '--no-shapes') flags.noShapes = true
else if (a === '--json') flags.json = true
else if (a === '--emit-verbatim') flags.emitVerbatim = true
else if (a === '--emit-brand-kit') flags.emitBrandKit = true
else if (a === '--elevate') flags.elevate = true
else if (a === '--template') flags.template = true
else if (a === '--name') flags.name = argv[++i]
else if (a === '--does') flags.does = argv[++i]
else if (a === '--personality') flags.personality = argv[++i]
else if (a === '--colors') flags.colors = argv[++i]
else if (a === '--logo') flags.logo = argv[++i]
else if (a === '--foot') flags.foot = argv[++i]
else if (a === '--present') flags.present = argv[++i]
else if (a === '--notes') flags.notes = argv[++i]
else if (a === '--founded') flags.founded = argv[++i]
else if (a === '-v' || a === '--version') { console.log(`@unpaper/cli ${VERSION}`); process.exit(0) }
else pos.push(a)
}
${b}https://www.unpaper.ai${r}
const die = (msg) => { console.error(msg); process.exit(1) }
${dim}A product of reHeritage GmbH.${r}
`)
try {
if (cmd === 'convert') {
if (!pos[0]) die('usage: unpaper convert <deck.html> [-o out.pptx]')
const { convert, formatReport } = await import('../src/convert.mjs')
const res = await convert({ path: pos[0], out: flags.out, selector: flags.selector, noShapes: flags.noShapes, browserPath: flags.browser })
if (flags.json) console.log(JSON.stringify({ pptxPath: res.pptxPath, slides: res.slides, report: res.report, durationMs: res.durationMs }))
else { console.log(formatReport(res)); console.log('written: ' + res.pptxPath) }
} else if (cmd === 'check') {
if (!pos[0]) die('usage: unpaper check <deck.html> [--json] | unpaper check <tpl.html> --template [--family <f>] [-o repaired.html]')
const { check, formatCheck } = await import('../src/check.mjs')
const res = check({ path: pos[0], template: flags.template, family: flags.family })
if (flags.template && flags.out && res.repairedHtml) {
writeFileSync(flags.out, res.repairedHtml)
console.error(`repaired template → ${flags.out}`)
}
console.log(flags.json ? JSON.stringify(flags.template ? { ...res, repairedHtml: undefined } : res) : formatCheck(res))
process.exit(res.ok ? 0 : 1)
} else if (cmd === 'templatize') {
const { templatize } = await import('../src/templatize.mjs')
let logoDataUri
if (flags.logo) {
const { readFileSync: read } = await import('node:fs')
const mime = /\.svg$/i.test(flags.logo) ? 'image/svg+xml' : /\.png$/i.test(flags.logo) ? 'image/png' : /\.webp$/i.test(flags.logo) ? 'image/webp' : 'image/jpeg'
logoDataUri = `data:${mime};base64,${read(flags.logo).toString('base64')}`
}
const res = templatize({
family: flags.family,
name: flags.name,
does: flags.does,
personality: flags.personality,
colors: flags.colors ? flags.colors.split(',').map((c) => c.trim()).filter(Boolean) : undefined,
logoDataUri,
foot: flags.foot,
present: flags.present ? flags.present.split(',').map((p) => p.trim()).filter(Boolean) : undefined,
notes: flags.notes,
})
if (flags.founded) {
if (!res.founded) die('--founded needs design mode: pass at least one brief flag (--name, --colors, --personality, …)')
writeFileSync(flags.founded, res.founded)
console.error(`founded base (already a valid template) → ${flags.founded}`)
}
if (flags.out) {
writeFileSync(flags.out, res.payload)
console.error(`${res.mode} payload (${res.baseFamily} base) → ${flags.out} — run it in your AI, then: unpaper check <result.html> --template`)
} else console.log(res.payload)
} else if (cmd === 'starter') {
const { starter, rulesDigest } = await import('../src/library.mjs')
const html = starter(flags.family ?? 'standard')
if (flags.out) { writeFileSync(flags.out, html); console.error(`starter → ${flags.out}\n\n${rulesDigest()}`) }
else console.log(html)
} else if (cmd === 'patterns') {
const { patterns, FAMILIES } = await import('../src/library.mjs')
const arg = pos[0]
console.log(patterns(arg ? (FAMILIES.includes(arg) ? { family: arg } : { id: arg }) : {}))
} else if (cmd === 'import') {
if (!pos[0]) die('usage: unpaper import <deck.pptx|doc.docx> [--emit-verbatim] [--emit-brand-kit]')
// E8.3: Word documents flow — they become Markdown, not slides
if (/\.docx$/i.test(pos[0])) {
const { readFile, writeFile } = await import('node:fs/promises')
const { createRequire } = await import('node:module')
const JSZip = createRequire(import.meta.url)('jszip')
const { docxToMarkdown } = await import('../dist/importer/docx2md.mjs')
const zip = await JSZip.loadAsync(await readFile(pos[0]))
const res = await docxToMarkdown(zip)
const outMd = flags.out ?? pos[0].replace(/\.docx$/i, '') + '.md'
await writeFile(outMd, res.markdown)
console.log(`docx2md: ${outMd} (${res.markdown.length} chars · ${res.images.length} image${res.images.length === 1 ? '' : 's'} as placeholders)`)
process.exit(0)
}
const { maybeElevate } = await import('../dist/importer/elevate.mjs')
const lifted = maybeElevate(pos[0], { force: Boolean(flags.elevate) })
if (lifted.elevated) console.error(`elevate: ${pos[0]} → clean .pptx via LibreOffice`)
const { runImport } = await import('../dist/importer/import.mjs')
const out = flags.out || pos[0].replace(/\.[^.]+$/, '') + '.unpaper.html'
const res = await runImport({ in: lifted.path, out, emitBrandKit: flags.emitBrandKit })
if (flags.emitVerbatim) {
const { readFile } = await import('node:fs/promises')
const { createRequire } = await import('node:module')
const JSZip = createRequire(import.meta.url)('jszip')
const { emitVerbatim } = await import('../dist/importer/verbatim.mjs')
const zip = await JSZip.loadAsync(await readFile(lifted.path))
const html = await emitVerbatim(zip, { title: pos[0], source: pos[0] })
const vOut = out.replace(/\.unpaper\.html$/i, '') + '.verbatim.html'
writeFileSync(vOut, html)
res.outputs.verbatim = vOut
}
console.log(JSON.stringify({ outputs: res.outputs, stats: res.stats }, null, 2))
} else if (cmd === 'rehydrate') {
if (!pos[0] || !pos[1]) die('usage: unpaper rehydrate <redesign.html> <deck.images.json> -o final.html')
const { runRehydrate } = await import('../dist/importer/rehydrate.mjs')
const res = await runRehydrate({ html: pos[0], registry: pos[1], out: flags.out || pos[0].replace(/\.html$/i, '') + '.final.html' })
console.log(`rehydrate: ${res.placed} image(s) → ${res.out}`)
if (!res.ok) {
if (res.unmatchedFigures.length) console.error(` ✗ placeholders not in registry: ${res.unmatchedFigures.join(', ')}`)
if (res.unusedRegistry.length) console.error(` ✗ registry images never placed: ${res.unusedRegistry.join(', ')}`)
process.exit(1)
}
} else if (cmd === 'mcp') {
const { serve } = await import('../src/mcp.mjs')
await serve()
} else {
console.log(`@unpaper/cli ${VERSION} — unPaper on your machine (local, offline, no AI inside)
convert <deck.html> [-o out.pptx] HTML deck → real, editable PowerPoint
check <deck.html> [--json] conformance L0–L3 + issues (exit 0/1)
check <tpl> --template [--family f] validate a company template (byte-law + repair)
starter [--family <f>] [-o f.html] print a ready-to-fill template
templatize [brief flags] [-o f.txt] company-template payload for YOUR AI (two modes)
patterns [family|ID] the pattern library (layout grammar)
import <deck.pptx|ppt|odp> rescue an existing deck for AI redesign
rehydrate <html> <images.json> splice real images back after redesign
mcp stdio MCP server for agents
Docs: https://www.unpaper.ai/llms.txt · a product of reHeritage GmbH`)
process.exit(cmd ? 1 : 0)
}
} catch (err) {
die(String(err?.message ?? err))
}
+21
-21
{
"name": "@unpaper/cli",
"version": "0.0.1",
"description": "unPaper CLI — ask any AI for print-ready HTML, get an editable PowerPoint. Runs in your browser at unpaper.ai; the CLI is on the roadmap.",
"keywords": [
"unpaper",
"slides",
"powerpoint",
"pptx",
"presentation",
"deck",
"html",
"html-to-pptx",
"ai"
],
"version": "0.2.0",
"description": "unPaper on your machine: convert self-contained HTML decks to real, editable PowerPoint; validate conformance; get starter templates and the pattern library. Local, offline, no AI inside. A product of reHeritage GmbH.",
"license": "SEE LICENSE IN LICENSE",
"author": "reHeritage GmbH",
"homepage": "https://www.unpaper.ai",
"bugs": {
"url": "https://www.unpaper.ai"
},
"author": "reHeritage GmbH",
"license": "UNLICENSED",
"type": "module",

@@ -28,3 +14,7 @@ "bin": {

"bin",
"README.md"
"src",
"dist",
"templates",
"README.md",
"LICENSE"
],

@@ -34,5 +24,15 @@ "engines": {

},
"publishConfig": {
"access": "public"
"scripts": {
"build": "node build.mjs"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"jszip": "^3.10.1",
"pptxgenjs": "^4.0.1",
"puppeteer-core": "^25.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"esbuild": "^0.24.0"
}
}
# @unpaper/cli
**Ask any AI for print-ready HTML, get an editable PowerPoint.**
unPaper on your machine: turn self-contained HTML decks into **real, editable
PowerPoint**, validate conformance, get starter templates and the pattern
library — and expose it all to AI agents over MCP. **Local, offline, no AI
inside.** A product of [reHeritage GmbH](https://www.unpaper.ai).
unPaper runs in your browser — fully client-side, nothing uploaded — at
**[unpaper.ai](https://www.unpaper.ai)**.
```
npx @unpaper/cli starter --family consulting -o deck.html
# …your AI writes content into deck.html…
npx @unpaper/cli check deck.html # L0–L3 conformance, exit 0/1
npx @unpaper/cli convert deck.html # → deck.pptx, fully editable
```
This package reserves `@unpaper/cli` under the `@unpaper` scope. A command-line
tool is on the roadmap; for now, `npx @unpaper/cli` points you to the web app.
## What you need
A product of **reHeritage GmbH**.
- **Node ≥ 18.**
- **A local Chrome, Chromium or Edge** (for `convert` only — the deck is
measured in a real browser). Discovery order: `--browser <path>` →
`UNPAPER_BROWSER`/`CHROME_PATH` env → standard install locations.
`check`, `starter` and `patterns` need no browser.
- Optional: **LibreOffice** enables `import` for legacy `.ppt`/`.pps`/`.odp`.
Everything runs on your machine. No network calls, no telemetry, no upload,
no AI. Your documents never leave your computer.
## Commands
| Command | What it does |
| --- | --- |
| `convert <deck.html> [-o out.pptx] [--selector .slide] [--no-shapes] [--json]` | HTML deck → .pptx with native text boxes, real tables, data-editable charts, movable shapes, speaker notes. Foreign (non-unPaper) fixed-size decks convert too via selector auto-detection. |
| `check <deck.html> [--json]` | Conformance level **L0–L3** + issues + a ready fix-request. **Exit code contract (CI-usable): `0` = level ≥ L1 and no errors, `1` otherwise.** |
| `check <tpl.html> --template [--family <f>] [-o repaired.html]` | Validate a **company template** against the byte-law: per-rule checks, mechanical re-lock of a drifted sizing script, fix request on failure. Base family auto-detects; same exit contract. |
| `starter [--family standard\|consulting\|design\|editorial] [-o f.html]` | Print a ready-to-fill template (self-contained, print-ready). |
| `templatize [-o payload.txt]` | The **company-template payload** for *your* AI. No flags = re-skin mode (attach the company's real slides in your AI). Brief flags (`--name`, `--does`, `--personality modern\|classic\|technical\|warm\|bold`, `--colors "#a,#b"`, `--logo file.svg`, `--foot`, `--present proposals,strategy,research,sales,board`, `--notes`) = from-scratch design mode; `--founded start.html` also writes the pre-founded base — already a valid template on its own. |
| `patterns [family\|ID]` | The pattern library: 90+ slide patterns with jobs, converter status, prompt fragments. |
| `import <deck.pptx\|ppt\|odp> [--emit-verbatim] [--emit-brand-kit]` | Rescue an existing deck into AI-readable unPaper HTML (content, tables, notes, chart data, image registry). Legacy formats elevate via local LibreOffice. |
| `rehydrate <redesign.html> <images.json> -o final.html` | Splice the real images back into an AI's redesign, by id, deterministically. |
| `mcp` | Run the stdio MCP server (below). |
## MCP server (for Claude Code, Claude Desktop, Cursor…)
```jsonc
// Claude Code (.mcp.json) / Claude Desktop (claude_desktop_config.json)
{ "mcpServers": { "unpaper": { "command": "npx",
"args": ["-y", "@unpaper/cli", "mcp"] } } }
```
Cursor (`~/.cursor/mcp.json`) and any generic stdio client take the same
`command`/`args` pair.
Seven tools: `starter → check → convert` (the happy path — an agent goes
brief → deck → validated → .pptx with zero copy-paste), plus `patterns`,
`import`/`rehydrate` for redesigning existing PowerPoints, and `templatize`
for a company's own reusable format (the agent executes the returned payload
itself, then validates with `check {template: true}` — there is no
generation inside unPaper). Set a client timeout of **≥ 120 s** — big decks
take time to measure and build. Browser discovery honors `UNPAPER_BROWSER`.
All server logging goes to stderr.
## License
See `LICENSE`. Hosted use of unpaper.ai and personal/commercial use of your
converted documents are free; the software is proprietary to reHeritage GmbH
and organisational self-hosting/embedding/resale requires a commercial
licence. The unPaper HTML **format specification** is open (CC BY 4.0):
https://www.unpaper.ai/spec