20 Commits
Author SHA1 Message Date
thePR0M3TH3AN f957adb99a PPTX chart import Phase 4c: value-axis ticks + gridlines
Bar / column / line charts now show the value-axis tick labels
that turn the chart from "abstract shapes" into "readable
data" — plus the horizontal / vertical gridlines that PowerPoint
draws when c:majorGridlines is present on either axis.

Helpers:

- _niceStep(range, targetTicks=5): pick an interval that's 1, 2,
  or 5 times a power of 10 — matches PowerPoint's auto-axis math.
  E.g. range 57 → step 10 (gives 0/10/20/30/40/50/60 ticks);
  range 1000 → step 200; range 0.5 → step 0.1.
- _ticksUpTo(maxV, step): array of evenly-spaced tick values from
  0 up through maxV.
- _chartHasMajorGridlines(chartDoc): does either c:catAx or
  c:valAx in the plotArea have a c:majorGridlines child?
- _buildGridlineLayers(chartDoc, pa, maxV, step, direction, ...):
  emit thin rect layers (#e0e0e0) across the plot area, one per
  tick. For 'col' direction (vertical value axis) the gridlines
  are horizontal; for 'bar' direction they're vertical. Emitted
  BEFORE the data shapes so bars / lines render on top via z-
  ordering.
- _buildAxisTickLayers(...): emit value-axis tick-label text
  layers — left side for 'col', bottom for 'bar'. 10pt, grey.

Per chart type:

- Bar: round maxV up to a tickStep multiple so the topmost bar
  aligns exactly with the top gridline + tick label. Gridlines
  before bars, ticks after data labels.
- Line: same; value axis is always vertical.

User-visible: gallery_template_layout_set.pptx slide 10's chart
now shows light grey horizontal gridlines at 10, 20, 30, 40, 50,
60 across the plot area, with matching numeric tick labels on
the left edge.

Pure-Node unit test for _niceStep + _ticksUpTo covers 12 cases
(integer-like, sub-1 fractions, zero / negative defensive). All
passing.
2026-05-30 23:38:57 -04:00
thePR0M3TH3AN aec0a51b1f PPTX chart import Phase 4b: data labels
Native rendering of c:dLbls — showVal / showCatName / showPercent /
showSerName flags. Series-level dLbls overrides chart-type-level
when both are present (matches PPTX inheritance rules).

Shared helpers:

- _readDataLabelOpts(dLblsNode): parse c:showVal / c:showCatName /
  c:showPercent / c:showSerName / c:dLblPos into a plain object.
  Returns null when the node is missing.
- _resolveDataLabelOpts(serNode, chartTypeNode): merge series-level
  over chart-type-level; returns null when no show* flag is set.
- _formatDataLabelText(opts, value, cat, total, name): join the
  enabled fields with \n (becomes <br> in the emitted HTML).
  showPercent uses Math.round(value/total*100).

Per-chart-type emission:

- Bar / column: text layer above each column ("outEnd"-like
  default position), or to the right of each horizontal bar.
  Reuses the bar-coord math; clamps to plot area so labels above
  the topmost bar don't escape upward.
- Pie / doughnut: text layer at the slice's angular midpoint,
  positioned 65 % of the way from centre to rim. Single-series
  c:ser so the merged opts apply to every slice.
- Line: text layer above each data point. Emitted in the marker
  loop so it's positioned even when markers themselves are
  disabled.

User-visible: gallery_template_layout_set.pptx slide 10's chart
has three c:dLbls blocks (one chart-level, two series-level) all
with showVal=0 by default — labels stay hidden unless a series
explicitly enables a flag. Decks that ship showVal=1 (the more
common configuration) now get readable numeric labels above each
bar / pie slice / line point.

Regression: polyline path test still passes.
2026-05-30 23:35:31 -04:00
thePR0M3TH3AN 1bfae87022 PPTX chart import Phase 4a: title + legend across all chart types
The "chart polish" layer that surrounds every chart type — title
text and series legend with colour swatches — now lands natively
instead of being silently dropped.

Shared helpers (used by all three builders):

- _readChartTitle(chartDoc): walks c:chart/c:title/c:tx/c:rich and
  joins a:p paragraphs + a:r runs. Returns '' when no title or
  when c:autoTitleDeleted="1" (the common "auto title suppressed"
  case for templates).
- _readChartLegend(chartDoc): reads c:chart/c:legend/c:legendPos@val
  (PPTX values 'r' / 'l' / 't' / 'b' / 'tr'; default 'r' when
  legend is present without an explicit position).
- _computePlotInsets(chartDoc, hasAxes): returns inset fractions
  for the plot area, growing the right (or left / top / bottom)
  inset to make room for the legend, and the top inset to make
  room for a title. hasAxes adds a 15 % bottom strip + 8 % left
  strip for category labels / value-axis tick labels (used by
  bar / line, not pie).
- _plotAreaFromInsets(gfBounds, insets): apply fractions.
- _buildTitleLayer: emit one centred-top text layer.
- _buildLegendLayers: emit (colour-rect + name-text) pair per
  series. Right / top-right stack vertically along the right
  edge; left mirrors; top / bottom flow horizontally.

Each chart builder now:
- Takes chartDoc as a parameter (dispatch in convertChart passes it
  through).
- Uses the shared inset / plot-area helpers (replaces the inline
  hand-rolled 5 % / 15 % math).
- Appends title + legend layers after its own slices / bars / lines.

Pie chart legend lists slice CATEGORIES (each in its own slice
colour) rather than series names — a single c:ser carries N
visual categories that the user thinks of as "series" in
PowerPoint.

Verified against the user's gallery_template_layout_set.pptx
slide 10: the chart has a c:legend block (and no title — flagged
autoTitleDeleted=1). After this commit the legend swatches +
"Baseline" / "Target" labels appear on the right side of the
chart in the imported deck.

Pure-Node polyline test still passes (no regression on Phase 3).
2026-05-30 23:32:29 -04:00
thePR0M3TH3AN 5360186384 PPTX chart import Phase 3: line charts with optional markers
c:lineChart now renders as native Artstr layers — one
kind:'path' polyline layer per series stroked in the series
colour, plus optional circle marker layers at each data point,
plus category-label text layers below the plot area.

Mechanics:

- _polylinePathD(xs, ys) builds an SVG "M x0 y0 L x1 y1 …" string
  in 0..100 viewBox coords with 3-decimal rounding. Single point
  emits a degenerate M+L for visibility; empty arrays return ''.

- buildLineChartLayers:
    - Collects categories from the first series, walks all series
      for { name, values, color, lineWidth, marker }.
    - Series colour comes from c:ser/c:spPr/a:ln/a:solidFill (the
      line stroke, NOT the spPr.solidFill that bar charts use).
      Falls back to CHART_DEFAULT_PALETTE by series index.
    - Line width from a:ln@w in EMU, converted via EMU_PER_IN
      directly (stroke widths are absolute in PPTX, not scaled
      with the slide). Default ~2 CSS px when unset.
    - Markers from c:marker. symbol != 'none' enables them;
      size in points from c:marker/c:size (default 5pt); marker
      fill from c:marker/c:spPr/a:solidFill overriding the line
      colour when present. All marker symbols approximate as
      circles for now (square / triangle / diamond / star is a
      Phase 4 polish).
    - Plot area uses the same 5 % / 15 % inset model as the bar
      chart so axes line up visually if both chart types appear
      on the same slide.
    - One path layer per series covers the whole plot area; the
      polyline d uses (i / (N-1)) · 100 for x and
      100 · (1 - v / maxV) for y. Single-category charts collapse
      to centre.
    - Marker layers are square-bounded circles centred on each
      data point.
    - Category labels emit centred under each data-point x, like
      the bar-chart category labels.

- convertChart dispatch grew a fourth branch (lineChart). The
  "common three" chart types are now all native.

Pure-Node unit test for _polylinePathD covers empty / single /
two / four-point / zigzag / fractional-rounding cases. All
passing.
2026-05-30 23:24:37 -04:00
thePR0M3TH3AN 21a5668e45 PPTX chart import Phase 2: pie + doughnut slice paths
c:pieChart and c:doughnutChart now emit native Artstr path-shape
layers — one per slice — instead of the placeholder image.

Mechanics:

- _sliceArcPath(cx, cy, outerR, startAngle, endAngle, innerR) builds
  the SVG d-attribute for a single slice. For pies it emits
  M cx cy L start A r r 0 large 1 end Z. For doughnuts (innerR>0)
  it emits M outerStart A outerArc L innerEnd A innerArc-back Z —
  a proper ring sector. Full-circle case (sweep ≈ 2π) emits two
  half-arcs so SVG can render it; full-ring case adds the inner
  pair.
- _readPieSliceColor honours PPTX precedence:
    1. c:ser/c:dPt[idx=N]/c:spPr/a:solidFill (per-slice override)
    2. c:varyColors val="1" (default for pies) → palette rotation
       indexed by sliceIdx using the existing CHART_DEFAULT_PALETTE
    3. c:ser/c:spPr/a:solidFill (uniform pie)
    4. Palette fallback as last resort.
- buildPieChartLayers picks the first c:ser, sums values for the
  total, reads holeSize (for doughnuts, default 50 %), reads
  firstSliceAng (default 0 = 12 o'clock), centres a square
  sub-bounds inside the chart's graphicFrame so the pie renders
  circular even on rectangular chart areas, then walks slices
  clockwise emitting one path-shape layer per non-zero value.
- convertChart dispatch grew two branches (pieChart, doughnutChart)
  that route into buildPieChartLayers with the isDoughnut flag.

Layer naming: "Chart 1 / Slice [Sales] (37)" so each slice in the
layer panel surfaces its category + value.

Pure-Node unit test for _sliceArcPath covers 14 cases — quarter
slice, major arc, doughnut ring sector, zero / negative / full
circle, full ring. All passing. Browser smoke test deferred until
CDP is available.
2026-05-30 09:25:11 -04:00
thePR0M3TH3AN 5588020812 PPTX chart import Phase 1: native bar / column rendering
Replaces the chart placeholder image with native Artstr layers for
the most common chart type — c:barChart in clustered grouping,
either col (vertical) or bar (horizontal) direction. The user's
gallery_template_layout_set.pptx slide 10 chart now produces 8
rect shape layers in #5E735D + #B05A44 plus 4 Q1-Q4 category-
label text layers instead of a single placeholder image.

What's in this commit (CHART_IMPORT_FEATURE.md §11 Phase 1):

- Cache-extraction helpers, no DOM-walking surprises:
    readCategoryLabels(catNode)  — c:strRef / c:multiLvlStrRef →
       string[] (collapses multi-level to the deepest level).
    readSeriesValues(valNode)    — c:val/c:numRef/c:numCache →
       number[], sparse indices filled with 0.
    readSeriesName(txNode)       — c:tx/c:strRef/c:strCache → string.
    readSeriesColor(serNode, theme, fallbackIdx) — explicit
       c:ser/c:spPr/a:solidFill (srgbClr or schemeClr via theme),
       falling back to a built-in 6-colour Office palette.

- buildBarChartLayers(barChartNode, gfBounds, chartLabel, ctx):
    - Reads series + categories + values from caches.
    - Plot area = chart bounds minus 5 % left/right inset and 15 %
      bottom inset for category labels (manualLayout in Phase 4).
    - For c:barDir val="col": catW = pa.w / N, S bars per category,
      each rect height = pa.h · value / maxValue.
    - For c:barDir val="bar": axes swapped.
    - Layer names: "Chart 1 / Bar [Q1, Baseline]" so the user can
      trace each rect back to its source data.
    - Category labels emitted below (col) or left of (bar) the plot
      area as text layers, 12pt centred / right-aligned.
    - Stacked / percentStacked groupings return null → fall through
      to placeholder (Phase 5).

- convertChart(chartDoc, gfBounds, chartLabel, ctx) dispatch reads
  the first chart-type child of c:plotArea and routes:
    barChart → buildBarChartLayers
    others   → null (placeholder fallback)

- convertGraphicFrame wired to try native chart conversion before
  emitting the placeholder. On success returns the layer array
  and bumps report.imported.{shapes,text}; on null returns the
  placeholder + UNSUPPORTED_CHART_PLACEHOLDER warning (existing
  behaviour) OR if the chart XML was readable but type wasn't
  supported, emits the new CHART_TYPE_UNSUPPORTED warning.

- ctx now carries files + slidePath so
  _readChartDocFromGraphicFrame can chase the c:chart r:id through
  slideRels to the chart XML in ppt/charts/chartN.xml and parse it.

- convertGraphicFrame now always returns an array (single-element
  for the placeholder path, N-element for native charts). walkSpTree
  updated to spread the result.

- New internals exposed for future tests: readCategoryLabels,
  readSeriesValues, readSeriesName, readSeriesColor,
  buildBarChartLayers, convertChart.

Browser smoke test still needed — pure-Node testing was blocked by
no @xmldom/xmldom on the system and the existing CDP-driven
harness needs Chrome with --remote-debugging-port=9222. Syntax
check + rich-text unit test both still pass.
2026-05-30 09:05:43 -04:00
thePR0M3TH3AN 4608f8b190 PPTX import: render straight lines as thin rects for true thickness
The Artstr line renderer floors stroke-width at 0.5 viewBox units,
which on a typical 5"-wide slide line bottoms out at ~2.5px — way
too thick for a real hairline (PPTX line widths are often 0.7–1pt
≈ 1–1.4px at 96dpi). Even with my dynamic strokeWidth fix the
floor was the binding constraint and lines came out 50–150% thicker
than the PowerPoint source.

Fix: for horizontal (cy=0) and vertical (cx=0) lines, switch the
layer representation from kind:'line' to kind:'rect' with a solid
fill of the source stroke colour and a bounding box sized to
exactly the source a:ln@w in inches. The rect's height (for
horizontal lines) or width (for vertical) IS the line thickness,
so what you see is pixel-accurate to PowerPoint.

The rect is centred on the original source line position
(bounds.y - strokeWidth/2 for horizontal, bounds.x - strokeWidth/2
for vertical) so it doesn't visually drift.

Diagonal lines keep kind:'line' — they can't be expressed as an
axis-aligned rect — and continue to use the fill-as-stroke-colour
quirk + (stroke.width / bounds.w) · 100 strokeWidth derivation,
subject to the renderer's 0.5-unit floor.

Editor implication: imported straight lines surface as
"Rectangle (<source name>)" layers in the panel rather than
"Line (<source name>)". Functionally identical for repositioning
+ recolouring; users who want a true line layer for further
editing (dash style, arrow heads, etc.) can re-draw with the
line tool. Future enhancement could attach a hint flag so the
editor offers "convert back to line".

Affects the 16 horizontal lines (slides 1–14, 16) and 4 vertical
lines (slide 15) in gallery_template_layout_set.pptx. They now
render at the exact PowerPoint thickness.
2026-05-30 08:48:30 -04:00
thePR0M3TH3AN 4f2ec01016 PPTX import: flat horizontal / vertical lines, no more slight tilt
PPTX expresses a horizontal line as cy=0 in the bounding box (the
line has no vertical extent) and a vertical line as cx=0. My
previous default of corner-to-corner (0,0)→(100,100) viewBox
endpoints turned that degenerate axis — after clamping to 2 pixels
of layer thickness — into a 0.26° slope across the line length,
which read on screen as a slight tilt + anti-aliasing fuzz that
also made the stroke look thicker than it really was.

Fix: detect the source extents and route endpoints by orientation:
  horizontal (cy=0, cx>0): x1=0, y1=50, x2=100, y2=50 (flat;
    flipH swaps x).
  vertical   (cx=0, cy>0): x1=50, y1=0, x2=50, y2=100 (flat;
    flipV swaps y).
  diagonal   (both > 0):   corner-to-corner with flipH/flipV
    swapping as before.

Also:
- Drop the min line thickness from 2px to 1px now that we don't
  need extra room for an angled diagonal to live inside.
- Centre the degenerate-axis clamp on the source position so the
  line stays exactly where PowerPoint put it (bounds.y -=
  MIN/2 + bounds.h = MIN, instead of just extending in +y).

Affects the 16 horizontal lines (slides 1-14, 16) and 4 vertical
lines (slide 15) in gallery_template_layout_set.pptx. The
remaining thickness overhead vs source (~30-40%) is the
renderer's hard 0.5-viewBox-unit stroke-width floor; truly exact
hairlines would need a different shape representation (e.g. thin
filled rect) which is a future enhancement.
2026-05-30 08:44:41 -04:00
thePR0M3TH3AN 9a7a0841df PPTX import: derive line stroke width from a:ln@w
Imported lines were rendering ~25x thicker than their PowerPoint
source because shape.strokeWidth was left at the _LINE_DEFAULTS
value of 6 viewBox units. The Artstr line renderer treats that
viewBox stroke as 6% of the layer's width, so a 4.58"-wide line
shape produced a 0.27" tall "bar" instead of a hairline. The
shapes were correctly positioned — they just looked like big
filled bars because the strokes were huge.

Fix: derive shape.strokeWidth from the source a:ln@w. Math:
the renderer sets vbH = 100·lh/lw so both viewBox axes share a
uniform scale of (lw_inches / 100) inches per viewBox unit. So
to target an on-screen stroke equal to the source thickness in
inches, set:
    shape.strokeWidth = (stroke.width_in_inches / lw_inches) · 100

The renderer's internal clamp to [0.5, 50] viewBox units acts as
a soft floor / ceiling for very wide or narrow lines (a 0.011"
hairline on a 5"-wide horizontal line bottoms out at ~0.5px
thick — visibly a line, just slightly thicker than source).

User-visible: re-importing gallery_template_layout_set.pptx now
yields lines at roughly their source thickness — the bottom-of-
slide #69746C hairlines look like hairlines, not bars.
2026-05-30 08:32:13 -04:00
thePR0M3TH3AN 29e731075e PPTX import: fix missing lines + add bent / curved connectors
Lines were getting silently dropped because their PPTX bounding
boxes are degenerate (cy=0 for horizontal lines, cx=0 for vertical),
and convertPresetShape's zero-area filter treated that as "skip".
Lines need the filter to be kind-aware: a 0-thickness layer for a
line is legitimate; for a rect it isn't.

Three fixes shipped together:

1. Move the zero-area filter to after the shape-kind lookup, then
   exempt shape.kind === 'line' from it. The bounding box is then
   clamped to a 2-pixel minimum thickness (~2/96 in) on the
   degenerate axis so the Artstr renderer has a non-zero pixel
   container to draw the SVG into (a 0-height layer renders into
   a 0-height DOM box regardless of overflow:visible inside).

2. Quirk of Artstr's line renderer: the inner <line> uses the
   layer's `fill` paint as its stroke colour, not the layer's
   stroke field (see buildShapeSvgInner in index.html). PPTX
   puts the line colour on a:ln, so for shape.kind === 'line'
   the imported stroke colour is now copied into the layer fill.
   Lines previously came in with a transparent fill + a real
   stroke that the renderer ignored — i.e. invisible. Now they
   render the right colour.

3. Add bent / curved connectors (bentConnector2..5, curvedConnector
   2..5) to PPTX_PRST_TO_ARTSTR as approximations of a straight
   line, with an UNSUPPORTED_CONNECTOR_APPROXIMATED warning so the
   user knows the joints / curves weren't preserved. The deck the
   user reported doesn't use these but other decks do.

Pure-Node unit test for _runToHtml (added in the prior commit)
still passes — the changes are confined to convertPresetShape.

User-visible: the 16 lines in gallery_template_layout_set.pptx
that were missing should now appear at the right positions with
their actual colour (e.g. the bottom-of-slide hairline at
#69746C).
2026-05-30 08:16:12 -04:00
thePR0M3TH3AN b6cf4f38eb PPTX import Phase 7: per-run rich text spans
Mid-paragraph styling now survives the import. Before this commit,
_readTxBody collapsed every paragraph to its first run's style; a
title like "Gallery Deck System" where one word was bold would
lose the bold. Now each run is emitted as HTML with inline
<b> / <i> / <span style="..."> wrappers, but only where it
disagrees with the layer-level dominant style — so plain runs
stay as plain text and the HTML stays clean.

Mechanics:

- _readParagraphRuns now returns an array of { text, style }
  per a:r / a:fld (text-bearing) or a:br (synthetic '\n' run with
  null style). null style means "inherit layer defaults" — the
  HTML builder emits unwrapped escaped text for these.
- _runsToPlainText is the new helper for callers that only want
  text (the notes-slide reader is the only one today).
- _runToHtml(run, dominant) builds the per-run HTML:
    - inline span for font-size / color / font-family overrides
      that disagree with the dominant; inner-most wrap.
    - <i> / <span style="font-style:normal"> for italic deltas.
    - <b> / <span style="font-weight:normal"> for bold deltas;
      outermost wrap.
  font-family values with spaces are escaped through &quot; so
  inner double-quotes can't terminate the style="..." attribute.
- _readTxBody picks the first explicit-rPr run anywhere in the
  body as the dominant style, then emits per-paragraph segments
  joined contiguously and paragraphs joined with <br>.
- The notes-slide reader uses _runsToPlainText to keep its plain-
  text concatenation behaviour.

Verified with a pure-Node unit test of _runToHtml covering 10
cases: plain run, mid-paragraph bold, color override, font-family
with spaces (HTML-entity quoted), size + family + italic combo,
HTML escaping, embedded \n → <br>, null-style inheritance, the
reverse-override "not-bold inside bold dominant", and an empty
dominant. All passing.

Browser CDP integration test isn't in this commit (the dev
server / Chrome session was offline at test time). The refactor
is otherwise structural: _readTxBody returns the same shape, and
the layer-level dominantStyle still drives the layer's fontSize /
color / family / align / bold / italic fields, so existing decks
should produce the same layer-level output with strictly richer
per-run HTML.
2026-05-30 08:06:57 -04:00
thePR0M3TH3AN a448afc077 PPTX import Phase 6: theme colors + bg inheritance + line flips
Three related upgrades to fidelity:

Theme color resolution:
- loadThemeForSlide(files, slidePath) chases the OOXML inheritance
  chain (slide → slideLayout → slideMaster → theme) and parses the
  a:clrScheme into a name → hex map for accent1..6, dk1/2, lt1/2,
  hlink, folHlink. sysClr entries fall back to @lastClr. The map
  also honours the bg/tx → lt/dk aliases that PPTX uses for light
  themes. Themes are cached by zip path so a 50-slide deck sharing
  one theme reparses zero times after the first slide; the cache
  is cleared at the start of each importPptxFile.
- convertColor(node, theme) now accepts a:srgbClr, a:schemeClr, and
  a:sysClr. schemeClr@val is resolved through the theme map.
- After a base hex is chosen, _readColorModifiers + _applyColorModifiers
  apply lumMod / lumOff / tint / shade in HSL (PPTX values are in
  1/1000 of a percent). Verified: accent1 (#ff0000) with lumMod
  60000 → #990000 — matches PowerPoint's render.

Background inheritance:
- readSlideBackground now falls back through the slide → layout →
  master chain when the slide has no direct p:bg or only a p:bgRef.
  Theme is passed through so schemeClr-based backgrounds also
  resolve. Verified: a slide with no own bg correctly inherits the
  master's #abcdef.

Line flip handling:
- a:prstGeom prst="line" / "straightConnector1" go corner-to-corner
  of the bounding box. PowerPoint flips them via xfrm@flipH /
  @flipV; the Artstr line shape stores its segment endpoints in a
  0-100 viewBox, so we set x1/y1/x2/y2 to the right diagonal at
  conversion time:
    no flip: (0,0)→(100,100)
    flipH:   (100,0)→(0,100)
    flipV:   (0,100)→(100,0)
    both:    (100,100)→(0,0)
  Previously every line came in as a flat horizontal at y=50; now
  diagonals show up correctly.

Plumbing: theme + groupXfrm + slideRels all live on ctx and are
threaded through walkSpTree, the converters, readFill, readStroke,
_readTxBody, _readParagraphRuns, _readRunStyle.

Real-deck regression (gallery_template_layout_set.pptx): 16
slides, 156 text, 64 shapes, 1 chart placeholder, 0 groups — all
counters identical to Phase 5. Synthetic theme-inheritance deck
passes every check (bg inheritance, scheme color, lumMod modifier,
line flip).
2026-05-29 15:37:40 -04:00
thePR0M3TH3AN 6a731d5d01 PPTX import Phase 5: flatten p:grpSp into slide-relative layers
Shapes nested inside p:grpSp were silently dropped in Phase 4 —
walkSpTree only knew about top-level sp / pic / graphicFrame
children. Phase 5 recurses into groups with a composed coordinate
transform so every child shape lands at its correct slide
position regardless of nesting depth.

Mechanics:

- IDENTITY_GROUP_XFRM { offX:0, offY:0, sX:1, sY:1 } is the
  starting transform for shapes at the top of a slide.
- enterGroup(parent, groupXfrmNode) composes a new transform when
  walking into a p:grpSp. PPTX semantics:
    - a:off + a:ext  give the group's position + size in the PARENT
      child-coord space.
    - a:chOff + a:chExt define the child-coord space INSIDE this
      group (the coord system the group's direct children live in).
  Composition first lifts the group itself into slide-EMU through
  the parent transform, then derives scale factors so a child at
  childExt = chExt maps onto groupSlideSize.
- readXfrm now accepts an optional groupXfrm (defaults to identity)
  and converts child-EMU → slide-EMU → inches in one shot. All four
  converters (preset / text / picture / graphicFrame) pass ctx.groupXfrm
  through unchanged.
- walkSpTree dispatches p:grpSp by reading grpSpPr/a:xfrm, composing
  the new transform via enterGroup, recursing into the group node
  itself (groups host their children directly), then restoring the
  prior transform on the way out. Sibling groups + top-level shapes
  see the original transform — no leakage.
- report.imported.groups counts each p:grpSp entered (useful for
  the import-report modal once we wire it).

Synthetic smoke test verifies the math exactly:
- Group at slide-EMU (1M, 1M) sized 4M x 2M with child-coord space
  0..2000 x 0..1000.
- Child A at child-coord (0, 0) ext (1000, 500)
    → expected (1.64, 1.64) at 3.28 x 1.64 inches  ✓
- Child B at child-coord (1000, 500) ext (1000, 500)
    → expected (4.92, 3.28) at 3.28 x 1.64 inches  ✓
- Outside sibling at slide-EMU (8M, 5M) ext (1M, 500K)
    → expected (13.12, 8.20) at 1.64 x 0.82 inches ✓
    (proves transform doesn't leak past the group)

Real-deck regression check (gallery_template_layout_set.pptx):
16 slides, 156 text layers, 64 shapes, 1 chart placeholder, 0
groups (this deck doesn't use groups) — counts identical to
Phase 3 output. No regression.
2026-05-29 15:26:44 -04:00
thePR0M3TH3AN 3237a69247 PPTX import Phase 3: image + chart / table / SmartArt placeholders
Every p:pic and every p:graphicFrame on a slide now becomes an
Artstr image layer pointing at the shared placeholder URL
(https://i.postimg.cc/9fY3n60q/example-image-replace-me.png). No
binary media is ever decoded from the .pptx package — the user
replaces each placeholder with their own URL after import, which
keeps imported decks Nostr-publishable out of the box and honours
Artstr's "remote URLs only" image model.

Pictures (p:pic):
- convertPicture reads spPr/xfrm for geometry, resolves the
  a:blip@r:embed relationship through the slide's _rels file, and
  uses the original media filename (basename of the relationship
  Target, URL-decoded) in the layer name: "Image: image3.png".
- Missing embed relationship → MISSING_IMAGE_RELATIONSHIP warning;
  the placeholder still lands.

GraphicFrames (p:graphicFrame):
- convertGraphicFrame reads xfrm directly off graphicFrame (not
  spPr — that's a graphicFrame quirk), then dispatches on
  a:graphic/a:graphicData@uri:
    • chart   → "Chart placeholder: <name>"      + UNSUPPORTED_CHART_PLACEHOLDER     + placeholders.charts++
    • table   → "Table placeholder: <name>"      + UNSUPPORTED_TABLE_PLACEHOLDER     + placeholders.tables++
    • diagram → "SmartArt placeholder: <name>"   + UNSUPPORTED_SMART_ART_PLACEHOLDER + placeholders.smartArt++
    • else    → "Unknown object placeholder"     + UNSUPPORTED_UNKNOWN_PLACEHOLDER   + placeholders.unknown++

walkSpTree now dispatches sp / pic / graphicFrame. readSlideRels
loads the per-slide _rels file into a Map<rId, {target,type}> once
per slide and passes it through ctx.slideRels.

Smoke-tested:
- gallery_template_layout_set.pptx: slide 10's chart now appears
  as 'Chart placeholder: Chart 0' (image layer, postimg URL, 9.3 x
  5.85 inches at 1.2, 3.08) with an UNSUPPORTED_CHART_PLACEHOLDER
  warning in the report.
- Synthetic multi-placeholder deck (1 picture + chart + table +
  SmartArt) exercises every branch: Image: image1.png from rels
  basename; chart / table / SmartArt placeholders with the right
  names and counters; positions and dimensions all correct in
  inches.
2026-05-29 15:20:28 -04:00
thePR0M3TH3AN 49d6697660 PPTX import Phase 2: text shapes + speaker notes
Two paths land together because they share paragraph/run parsing.

Text shapes (p:sp with p:txBody):
- _readTxBody walks a:p paragraphs and a:r / a:fld runs, joining
  paragraph text with \n internally then escaping + converting to
  <br>-joined HTML on the way out.
- _readRunStyle pulls the first run's a:rPr → fontSize (sz is
  hundredths-of-pt → pt), bold (b), italic (i), color
  (a:solidFill/a:srgbClr), and explicit font family
  (a:latin@typeface, but skip theme placeholders like '+mj-lt').
- a:pPr@algn → Artstr alignment via PPTX_ALIGN_TO_ARTSTR map
  (l/ctr/r/just). First paragraph's alignment wins for the whole
  box.
- convertTextShape emits an Artstr text layer with the dominant
  style applied to the whole box; xfrm provides bounds.
- Shapes without explicit xfrm (text inheriting placeholder
  geometry from a layout / master) warn TEXT_STYLE_APPROXIMATED
  and skip — proper placeholder resolution is Phase 6.
- walkSpTree dispatches p:sp to convertTextShape if a p:txBody
  child is present, otherwise to convertPresetShape. Bumps
  report.imported.text or .shapes accordingly.

Speaker notes:
- readSlideNotes(files, slidePath, slideIndex, report) reads the
  slide's _rels/<name>.xml.rels, finds the .../notesSlide
  relationship, resolves the path, and walks the notesSlide's
  spTree concatenating every p:sp/p:txBody's plain text.
- Filters out the slide-number stamp (PowerPoint embeds the
  current slide number as a "1" / "2" text on the notes-slide
  image placeholder; matching ^\d+$ drops it).
- Result goes into slide.notes, which Presenter Mode already
  renders. report.imported.notes counts non-empty slides.

Smoke-tested against gallery_template_layout_set.pptx (16 slides):
- 156 text layers imported, zero warnings.
- Slide 1 title 'Gallery\\nDeck System' lands as
  'Gallery<br>Deck System' at 49pt Aptos Display bold #17202a.
- Subtitle EDITORIAL TEMPLATE KIT lands at 8.5pt Aptos bold
  #b05a44 — exact source color.
- 0 notes (this template-only deck doesn't ship with notes).
Synthetic deck still produces an empty deck shell with no
regressions.
2026-05-29 15:13:16 -04:00
thePR0M3TH3AN feace5e78c PPTX import: fix unit conversion — layer coords go to inches, not pixels
Phase 4 was emitting layer x/y/w/h in pixel units, but Artstr's
canvas system (slide / customart / deck) treats layer coordinates
as *inches* — it derives the on-screen canvas size by dividing
slide.width by 96 (customArtCanvasInches() at line 6813). So a
shape at x=1000, w=500 was being rendered as "1000 inches from the
left, 500 inches wide" — about 96x bigger than intended.

Fix: introduce TARGET_W_IN / TARGET_H_IN (20 / 11.25, i.e. 1920/96
and 1080/96) and rescale EMU through those instead of TARGET_W /
TARGET_H. slide.width / slide.height stay at 1920 / 1080 because
that's what the engine expects in slide.width.

readStroke also assumed pixel units — Math.max(1, ...) clamped
strokes to "at least 1 inch" after the scale flip, which would
have produced impossibly thick lines once the rest of the fix
landed. Now clamps to 1/96 inch (~1 pixel) minimum.

After fix, the gallery_template_layout_set.pptx full-slide backing
rectangle imports at exactly 20.00 x 11.25 inches with a 0.02
inch (~2 pt) stroke, which matches the source slide dimensions.
2026-05-29 15:06:02 -04:00
thePR0M3TH3AN 3def41b184 PPTX import Phase 4: basic preset shapes with fills + strokes
Walks each slide's p:cSld/p:spTree and turns every non-text-bodied
p:sp with a:prstGeom into an Artstr shape layer. After Phase 1+1.5
slides imported as colored backgrounds with no layers; after this
they come in with most of their visible design.

What landed in src/pptx-importer.js:

- PPTX_PRST_TO_ARTSTR table mapping rect, roundRect, ellipse,
  triangle, line, straightConnector1, star5, hexagon, pentagon
  to Artstr shape kinds. Unknown prst values fall back to rect
  with an UNSUPPORTED_SHAPE_APPROXIMATED warning.
- convertColor: a:srgbClr → '#rrggbb'. Scheme / preset / system
  colors return null and trigger theme-resolution warnings later.
- readFill: walks spPr for a:solidFill / a:noFill, returns an
  Artstr fill descriptor. Direct-child-only lookups so a fill
  nested in a:ln doesn't leak through.
- readStroke: walks spPr/a:ln for solid color + width (EMU→px via
  the slide scale) + dash style. Maps the OOXML dash family to
  Artstr's three states (solid / dashed / dotted).
- readXfrm: a:xfrm/a:off + a:xfrm/a:ext → {x,y,w,h} in slide-pixel
  coords; a:xfrm@rot (1/60000 deg) → degrees. Skips zero-area
  shapes.
- convertPresetShape: pulls the above together, names the layer
  with the source p:cNvPr@name for traceability, skips shapes that
  carry a p:txBody (Phase 2 will handle text shapes).
- walkSpTree: top-level walk of spTree children. p:grpSp is Phase
  5; p:pic / p:graphicFrame are Phase 3.
- importPptxFile wires the walk into the per-slide loop; layer ids
  use a per-import counter prefix so two imports can't collide.

Smoke-tested against the user's gallery_template_layout_set.pptx
(16 design-template slides):
- 16 slides, 16 backgrounds, 64 shape layers, 0 warnings.
- Slide 1 imports the cream backing rect + green rect + gold and
  terracotta ellipses + two cream rounded rectangles, with their
  exact source colors (#5e735d, #c8a658, #b05a44, etc.).
- Editor loads in deck mode with the layers visible.
Synthetic 3-slide pptx still produces white slides + 0 layers, so
no regression on the Phase 1 baseline.
2026-05-29 15:00:57 -04:00
thePR0M3TH3AN 32af7f0e96 PPTX import P1.5: slide backgrounds (direct solid fills)
Quick win on top of Phase 1: pull each slide's direct background
color out of p:bg/p:bgPr/a:solidFill/a:srgbClr and set it on the
Artstr slide instead of always leaving the default white.

Scoped narrowly:

- Direct slide backgrounds only. p:bgRef (layout/master inheritance)
  emits LAYOUT_INHERITANCE_PARTIAL and stays white — Phase 6.
- solidFill + srgbClr only. Gradient, picture, and scheme-color
  fills emit THEME_COLOR_UNRESOLVED and stay white — Phase 6.
- Parse failures on a per-slide basis emit SLIDE_PARSE_FAILED and
  fall through to white; the deck shell still imports.

Counts successful backgrounds in report.imported.backgrounds so the
import report can show "16 backgrounds imported".

readSlideBackground is exposed via _internals for future-phase tests.

Verified end-to-end against the user's gallery_template_layout_set
deck (16 slides, design-template set heavy on background colors):
all 16 slides now import with their actual cream / themed colors
(e.g. slide 1 = #fbf8f0) instead of plain white, with zero warnings.
Synthetic-pptx smoke test (no p:bg) still produces white slides.
2026-05-29 14:55:21 -04:00
thePR0M3TH3AN 2cc2b85883 PPTX import Phase 1: deck-shell parser
Replace the Phase 0 "not implemented" stub with a working deck-shell
import. A .pptx file in becomes a templateMode:'deck' payload out,
one empty Artstr slide per PowerPoint slide, with no layer extraction
yet (Phase 2+ adds text, notes, images, shapes).

What landed in src/pptx-importer.js:

- readPptxPackage(file): file -> ArrayBuffer -> fflate.unzipSync ->
  { [path]: Uint8Array }. Throws on missing fflate or invalid zip.
- parseXml(xmlText): DOMParser wrapper that detects <parsererror>
  and re-throws so silent malformed-doc cases don't slip through.
- pptxTargetToZipPath(basePath, target): resolves OOXML relationship
  Target values against the owning part's path. Handles relative,
  absolute-in-package, and ./.. segments.
- readSlideSize(presDoc): reads p:sldSz cx/cy (EMU). Falls back to
  16:9 widescreen default (12192000 x 6858000) when absent.
- readSlideRefsInOrder(presDoc, presRelsDoc): walks p:sldIdLst for
  canonical order, resolves each r:id through the rels file to the
  actual zip path of the slide XML. Handles both getAttributeNS for
  the relationships namespace and the plain "r:id" attribute form.
- readPresentationInfo(files, report): bundles size + slide refs.
  Adds a LARGE_DECK warning at >100 slides.
- importPptxFile(file, report): reads the package, parses
  presentation info, emits one empty deck slide per ref at 1920x1080
  with #ffffff bg + ignoreDeckTheme:true. Yields to RAF every 10
  slides so a 50+ slide deck doesn't lock the UI. Returns a
  templateMode:'deck' payload ready for loadProjectFromText.

Internals are exposed via window.ArtstrPptxImporter._internals for
future-phase tests and harnesses.

Smoke-tested via CDP against a synthetic .pptx built in-browser with
fflate.zipSync:
- 3-slide deck imports with correct count, dims, bg, ignoreDeckTheme.
- 5-slide deck round-trips through loadProjectFromText: editor
  comes up with templateMode='deck', 5 slides, body has template-deck
  class (Deck Builder live).
- Garbage bytes throw a clean "invalid zip data" error instead of
  silently producing a zero-slide deck.
2026-05-29 14:32:56 -04:00
thePR0M3TH3AN d710b84d19 PPTX import Phase 0: plumbing + lazy-load shell
Sets up the import-pipeline plumbing without doing any actual PPTX
parsing yet. Acceptance criteria from the spec's Phase 0:

- Vendor fflate (0.8.2 UMD) at src/vendor/fflate.min.js so the
  importer can unzip .pptx packages without a build step. fflate
  exposes window.fflate via the UMD shim.
- Add src/pptx-importer.js exposing window.ArtstrPptxImporter with
  PPTX_IMAGE_PLACEHOLDER_URL, makePptxImportReport, and a stub
  importPptxFile that throws "not implemented yet — Phase 1 lands
  the deck-shell parser." Phase 1 will fill this out.
- Wire loadScriptOnce + ensurePptxImporterLoaded in index.html.
  Both fflate and pptx-importer.js are lazy-loaded on the first
  click of Import PPTX so the main bundle stays lean for users
  who never use this path. Subsequent clicks are no-ops.
- Add an Import PowerPoint (.pptx) button + hidden file input to
  the Deck Builder's Slides fieldset, next to the existing
  Import slide buttons. Click flow:
    button click -> ensurePptxImporterLoaded (with a "Loading…"
    label on the button while scripts arrive) -> hidden file
    input click -> change -> onPptxFileChosen -> import + report.
- Add the pptxImportReportModal shell with file name, summary
  counts, placeholder note, and a collapsible warnings list.
  Render is driven by the report data shape defined in spec
  section 13. Close on background click or Close button.

Smoke-tested via CDP: first click lazy-loads fflate +
pptx-importer; subsequent calls reuse cached promises; file
picker triggers; Phase 0 stub returns the expected
not-implemented error which surfaces as a toast.
2026-05-29 13:51:09 -04:00