{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"doc-viewer","type":"registry:ui","title":"DocViewer","description":"A last-word Doc as Word renders it, framed.","package":"fancy-docs","dependencies":["@particle-academy\/react-fancy","@particle-academy\/fancy-echarts"],"registryDependencies":[],"files":[{"path":"components\/fancy\/doc-viewer\/DocViewer.tsx","content":"import { Fragment, type CSSProperties, type ReactNode } from \"react\";\nimport { FauxClient, sanitizeHref, type FauxClientProps } from \"@particle-academy\/react-fancy\";\nimport type { Band, Block, Doc, ListItem, Run, TableBorders, TableCell } from \".\/types\";\nimport { borderCss, boxBorders, DEFAULTS, fontStack, hex, num, padding, PAGE_SIZES, paragraphCss, runCss } from \".\/style\";\nimport { ChartBlock, isPlottable } from \".\/ChartBlock\";\n\nexport interface DocViewerProps {\n  \/** A last-word document, as its readers return it. *\/\n  doc: Doc;\n  \/**\n   * Show the document in react-fancy's `FauxClient` frame: zoom and full screen,\n   * revealed on hover, like every file viewer. Default `true`.\n   *\/\n  frame?: boolean;\n  \/** Passed to the frame: `variant`, a controlled `zoom`, `onFullscreenChange`, \u2026 *\/\n  frameProps?: Partial<FauxClientProps>;\n  className?: string;\n}\n\n\/**\n * A last-word document rendered the way Word renders it: page size, margins and\n * running header \/ footer per page; runs, paragraphs, lists, tables (fills,\n * borders, spans, alignment), code, quotes and images.\n *\n * Measurements stay in points, the model's own unit, so a 0.5pt rule is a 0.5pt\n * rule and the page is the page's size; the frame scales the whole sheet to fit\n * and zooms it.\n *\n * Everything shown came out of a file, so links go through react-fancy's\n * `sanitizeHref`, images are `data:` rasters only (a remote URL in a document\n * is a tracking pixel waiting to fire), and colours must be `#RRGGBB`.\n *\/\nexport function DocViewer({ doc, frame = true, frameProps, className }: DocViewerProps) {\n  const pages = splitPages(Array.isArray(doc?.blocks) ? doc.blocks : []);\n  const total = pages.length;\n\n  const sheets = (\n    <div\n      data-fancy-docs-viewer=\"\"\n      className={className}\n      style={{ display: \"flex\", flexDirection: \"column\", alignItems: \"center\", gap: 16, padding: 16, background: \"#E5E7EB\" }}\n    >\n      {pages.map((blocks, i) => (\n        <PageSheet key={i} doc={doc} blocks={blocks} number={i + 1} total={total} \/>\n      ))}\n    <\/div>\n  );\n\n  if (!frame) return sheets;\n\n  \/\/ The frame lays the sheets out at their real width and scales them to fit,\n  \/\/ as a word processor opens at \"fit page width\"; zoom works on top of that.\n  return (\n    <FauxClient\n      variant=\"device\"\n      controls\n      fill\n      width={logicalWidth(doc)}\n      scale=\"fit\"\n      bodyClassName=\"overflow-auto\"\n      {...frameProps}\n    >\n      {sheets}\n    <\/FauxClient>\n  );\n}\n\n\/** The sheets' width in CSS pixels, gutter included: the frame's logical width. *\/\nfunction logicalWidth(doc: Doc): number {\n  const page = doc?.page ?? {};\n  const [w, h] = PAGE_SIZES[page.size ?? \"letter\"] ?? PAGE_SIZES.letter;\n  const width = page.orientation === \"landscape\" ? h : w;\n  return Math.ceil((width * 4) \/ 3) + 32;\n}\n\n\/** Page breaks split the flow into sheets; an empty document is one blank sheet. *\/\nfunction splitPages(blocks: Block[]): Block[][] {\n  const pages: Block[][] = [[]];\n  for (const block of blocks) {\n    if (block?.type === \"pageBreak\") pages.push([]);\n    else pages[pages.length - 1].push(block);\n  }\n  return pages;\n}\n\nfunction PageSheet({ doc, blocks, number, total }: { doc: Doc; blocks: Block[]; number: number; total: number }) {\n  const page = doc.page ?? {};\n  const [w, h] = PAGE_SIZES[page.size ?? \"letter\"] ?? PAGE_SIZES.letter;\n  const [width, height] = page.orientation === \"landscape\" ? [h, w] : [w, h];\n  const m = page.margins ?? {};\n  const margin = (side: \"top\" | \"right\" | \"bottom\" | \"left\") => num(m[side]) ?? DEFAULTS.margin;\n\n  const style: CSSProperties = {\n    position: \"relative\",\n    width: `${width}pt`,\n    minHeight: `${height}pt`,\n    background: \"#FFFFFF\",\n    color: \"#000000\",\n    boxShadow: \"0 1px 3px rgba(0,0,0,0.15)\",\n    fontFamily: fontStack(doc.defaultFont ?? DEFAULTS.font),\n    fontSize: `${num(doc.defaultSize) ?? DEFAULTS.size}pt`,\n    flexShrink: 0,\n  };\n\n  return (\n    <section data-fancy-docs-page={number} style={style}>\n      {page.header && <BandLine band={page.header} kind=\"header\" number={number} total={total} left={margin(\"left\")} right={margin(\"right\")} \/>}\n      <div\n        data-fancy-docs-body=\"\"\n        style={{ paddingTop: `${margin(\"top\")}pt`, paddingRight: `${margin(\"right\")}pt`, paddingBottom: `${margin(\"bottom\")}pt`, paddingLeft: `${margin(\"left\")}pt` }}\n      >\n        {blocks.map((block, i) => (\n          <BlockView key={i} block={block} \/>\n        ))}\n      <\/div>\n      {page.footer && <BandLine band={page.footer} kind=\"footer\" number={number} total={total} left={margin(\"left\")} right={margin(\"right\")} \/>}\n    <\/section>\n  );\n}\n\n\/** A running header or footer: left \/ center \/ right, with `{page}` and `{pages}` filled in. *\/\nfunction BandLine({ band, kind, number, total, left, right }: { band: Band; kind: \"header\" | \"footer\"; number: number; total: number; left: number; right: number }) {\n  const fill = (s: unknown) => (typeof s === \"string\" ? s.split(\"{page}\").join(String(number)).split(\"{pages}\").join(String(total)) : \"\");\n  const style: CSSProperties = {\n    position: \"absolute\",\n    left: `${left}pt`,\n    right: `${right}pt`,\n    [kind === \"header\" ? \"top\" : \"bottom\"]: `${DEFAULTS.bandDistance}pt`,\n    display: \"grid\",\n    gridTemplateColumns: \"1fr auto 1fr\",\n    fontSize: `${num(band.size) ?? DEFAULTS.bandSize}pt`,\n    color: hex(band.color) ?? \"#595959\",\n  };\n  return (\n    <div data-fancy-docs-band={kind} style={style}>\n      <span style={{ textAlign: \"left\" }}>{fill(band.left)}<\/span>\n      <span style={{ textAlign: \"center\" }}>{fill(band.center)}<\/span>\n      <span style={{ textAlign: \"right\" }}>{fill(band.right)}<\/span>\n    <\/div>\n  );\n}\n\nfunction Runs({ runs }: { runs: Run[] | undefined }) {\n  return (\n    <>\n      {(Array.isArray(runs) ? runs : []).map((run, i) => (\n        <RunView key={i} run={run} \/>\n      ))}\n    <\/>\n  );\n}\n\nfunction RunView({ run }: { run: Run }) {\n  const text = typeof run?.text === \"string\" ? run.text : \"\";\n  const style = runCss(run);\n  const href = typeof run.link === \"string\" ? sanitizeHref(run.link) : undefined;\n\n  if (run.code) {\n    style.fontFamily = style.fontFamily ?? fontStack(DEFAULTS.codeFont, \"monospace\");\n    style.backgroundColor = style.backgroundColor ?? DEFAULTS.codeFill;\n  }\n  if (href) {\n    style.color = style.color ?? DEFAULTS.linkColor;\n    style.textDecorationLine = style.textDecorationLine ?? \"underline\";\n  }\n\n  \/\/ Line breaks inside a run are real breaks, as Word shows them.\n  const parts = text.split(\"\\n\");\n  const content = parts.map((part, i) => (\n    <Fragment key={i}>\n      {i > 0 && <br \/>}\n      {part}\n    <\/Fragment>\n  ));\n  const span = run.code ? (\n    <code data-fancy-docs-run=\"\" style={style}>{content}<\/code>\n  ) : (\n    <span data-fancy-docs-run=\"\" style={style}>{content}<\/span>\n  );\n\n  return href ? (\n    <a href={href} target=\"_blank\" rel=\"noopener noreferrer nofollow\">\n      {span}\n    <\/a>\n  ) : (\n    span\n  );\n}\n\nfunction BlockView({ block }: { block: Block }): ReactNode {\n  switch (block?.type) {\n    case \"heading\": {\n      const level = Math.min(6, Math.max(1, Math.round(num(block.level) ?? 1)));\n      const Tag = `h${level}` as \"h1\";\n      const style: CSSProperties = {\n        ...paragraphCss(block, { before: DEFAULTS.headingSpaceBefore, after: DEFAULTS.headingSpaceAfter }),\n        fontSize: `${DEFAULTS.headingSizes[level - 1]}pt`,\n        fontWeight: 700,\n      };\n      return (\n        <Tag data-fancy-docs-block=\"heading\" style={style}>\n          <Runs runs={block.runs} \/>\n        <\/Tag>\n      );\n    }\n    case \"paragraph\":\n      return (\n        <p data-fancy-docs-block=\"paragraph\" style={paragraphCss(block)}>\n          <Runs runs={block.runs} \/>\n        <\/p>\n      );\n    case \"list\":\n      return <ListView items={block.items} ordered={!!block.ordered} \/>;\n    case \"table\":\n      return <TableView block={block} \/>;\n    case \"code\":\n      return (\n        <pre\n          data-fancy-docs-block=\"code\"\n          style={{\n            margin: `0 0 ${DEFAULTS.spaceAfter}pt`,\n            padding: \"4pt 6pt\",\n            background: DEFAULTS.codeFill,\n            fontFamily: fontStack(DEFAULTS.codeFont, \"monospace\"),\n            fontSize: `${DEFAULTS.codeSize}pt`,\n            lineHeight: 1,\n            whiteSpace: \"pre-wrap\",\n          }}\n        >\n          <code>{typeof block.text === \"string\" ? block.text : \"\"}<\/code>\n        <\/pre>\n      );\n    case \"quote\":\n      return (\n        <blockquote\n          data-fancy-docs-block=\"quote\"\n          style={{ margin: `0 0 0 ${DEFAULTS.quoteIndent}pt`, fontStyle: \"italic\", color: DEFAULTS.quoteColor }}\n        >\n          {(Array.isArray(block.blocks) ? block.blocks : []).map((b, i) => (\n            <BlockView key={i} block={b} \/>\n          ))}\n        <\/blockquote>\n      );\n    case \"image\": {\n      \/\/ Rasters embedded in the file only: a remote URL would fetch as soon as\n      \/\/ the document opened -- a tracking pixel in a file someone sent you.\n      const src = typeof block.src === \"string\" && \/^data:image\\\/(png|jpeg|gif|webp);base64,[A-Za-z0-9+\/=]+$\/.test(block.src) ? block.src : null;\n      if (!src) return null;\n      return (\n        <img\n          data-fancy-docs-block=\"image\"\n          src={src}\n          alt={typeof block.alt === \"string\" ? block.alt : \"\"}\n          style={{ display: \"block\", maxWidth: \"100%\", width: num(block.widthPx), height: num(block.heightPx) ? undefined : \"auto\", margin: `0 0 ${DEFAULTS.spaceAfter}pt` }}\n        \/>\n      );\n    }\n    case \"chart\":\n      \/\/ An option with no series draws an empty box; skip it rather than show one.\n      return isPlottable(block) ? <ChartBlock block={block} \/> : null;\n    case \"hr\":\n      return <hr data-fancy-docs-block=\"hr\" style={{ border: 0, borderTop: \"0.5pt solid #000000\", margin: `0 0 ${DEFAULTS.spaceAfter}pt` }} \/>;\n    default:\n      return null;\n  }\n}\n\nfunction ListView({ items, ordered }: { items: ListItem[] | undefined; ordered: boolean }) {\n  const Tag = ordered ? \"ol\" : \"ul\";\n  return (\n    <Tag data-fancy-docs-block=\"list\" style={{ margin: `0 0 ${DEFAULTS.spaceAfter}pt`, paddingLeft: \"18pt\", listStyleType: ordered ? \"decimal\" : \"disc\" }}>\n      {(Array.isArray(items) ? items : []).map((item, i) => (\n        <li key={i} style={{ ...paragraphCss(item, { after: 0 }) }}>\n          <Runs runs={item.runs} \/>\n          {Array.isArray(item.children) && item.children.length > 0 && <ListView items={item.children} ordered={ordered} \/>}\n        <\/li>\n      ))}\n    <\/Tag>\n  );\n}\n\ntype TableBlock = Extract<Block, { type: \"table\" }>;\n\nfunction TableView({ block }: { block: TableBlock }) {\n  const rows = Array.isArray(block.rows) ? block.rows : [];\n  const widths = Array.isArray(block.widths) ? block.widths.filter((w) => num(w) !== undefined && w > 0) : [];\n  const sum = widths.reduce((a, b) => a + b, 0);\n  const width = num(block.width);\n  const borders: TableBorders = block.borders ?? {};\n\n  const style: CSSProperties = {\n    borderCollapse: \"collapse\",\n    width: width !== undefined ? `${width}%` : \"100%\",\n    tableLayout: widths.length > 0 ? \"fixed\" : \"auto\",\n    marginBottom: `${DEFAULTS.spaceAfter}pt`,\n    ...(block.align === \"center\" ? { marginLeft: \"auto\", marginRight: \"auto\" } : block.align === \"right\" ? { marginLeft: \"auto\" } : {}),\n  };\n\n  \/\/ Every edge defaults to the writer's 0.5pt single line; `{ style: \"none\" }` removes one.\n  const edge = (b: TableBorders[keyof TableBorders]) => borderCss(b) ?? `${DEFAULTS.borderWidth}pt solid #000000`;\n\n  return (\n    <table data-fancy-docs-block=\"table\" style={style}>\n      {widths.length > 0 && (\n        <colgroup>\n          {widths.map((w, i) => (\n            <col key={i} style={{ width: `${Math.round((w \/ sum) * 1000) \/ 10}%` }} \/>\n          ))}\n        <\/colgroup>\n      )}\n      <tbody>\n        {rows.map((row, r) => (\n          <tr key={r}>\n            {(Array.isArray(row.cells) ? row.cells : []).map((cell, c, cells) => {\n              const header = !!row.header;\n              const Tag = header ? \"th\" : \"td\";\n              const first = c === 0;\n              const last = c === cells.length - 1;\n              const cellStyle: CSSProperties = {\n                \/\/ Outer edges from the table's box; inner ones from insideH \/ insideV.\n                borderTop: r === 0 ? edge(borders.top) : edge(borders.insideH),\n                borderBottom: r === rows.length - 1 ? edge(borders.bottom) : edge(borders.insideH),\n                borderLeft: first ? edge(borders.left) : edge(borders.insideV),\n                borderRight: last ? edge(borders.right) : edge(borders.insideV),\n                ...padding(cell.padding, { ...DEFAULTS.cellPadding, ...(block.cellPadding ?? {}) }),\n                ...boxBorders(cell.borders),\n                verticalAlign: cell.valign === \"center\" ? \"middle\" : cell.valign ?? \"top\",\n                textAlign: \"left\",\n                fontWeight: header ? 700 : undefined,\n                backgroundColor: hex(cell.shading) ?? (header ? DEFAULTS.headerFill : undefined),\n              };\n              return (\n                <Tag\n                  key={c}\n                  style={cellStyle}\n                  colSpan={num(cell.colSpan) && cell.colSpan! > 1 ? cell.colSpan : undefined}\n                  rowSpan={num(cell.rowSpan) && cell.rowSpan! > 1 ? cell.rowSpan : undefined}\n                >\n                  <CellBlocks cell={cell} \/>\n                <\/Tag>\n              );\n            })}\n          <\/tr>\n        ))}\n      <\/tbody>\n    <\/table>\n  );\n}\n\n\/** A cell's blocks, with the last paragraph's trailing space dropped as Word does inside a cell. *\/\nfunction CellBlocks({ cell }: { cell: TableCell }) {\n  const blocks = Array.isArray(cell.blocks) ? cell.blocks : [];\n  return (\n    <>\n      {blocks.map((b, i) =>\n        b?.type === \"paragraph\" ? (\n          <p key={i} data-fancy-docs-block=\"paragraph\" style={{ ...paragraphCss(b), ...(i === blocks.length - 1 && num(b.spaceAfter) === undefined ? { marginBottom: 0 } : {}) }}>\n            <Runs runs={b.runs} \/>\n          <\/p>\n        ) : (\n          <BlockView key={i} block={b} \/>\n        ),\n      )}\n    <\/>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/doc-viewer\/DocViewer.tsx"},{"path":"components\/fancy\/doc-viewer\/types.ts","content":"\/**\n * The last-word document model, as its readers (PHP and JS, one schema) return\n * it. Structural on purpose -- no dependency on last-word -- and loose, because\n * a read document is data from a file, not something this package wrote.\n *\/\n\nexport type Align = \"left\" | \"center\" | \"right\" | \"justify\";\nexport type VAlign = \"top\" | \"center\" | \"bottom\";\nexport type BorderStyle = \"single\" | \"double\" | \"dashed\" | \"dotted\" | \"none\";\n\nexport interface Border {\n  style?: BorderStyle;\n  \/** Points. Default 0.5. *\/\n  width?: number;\n  \/** #RRGGBB *\/\n  color?: string;\n}\n\nexport interface BoxBorders {\n  top?: Border;\n  right?: Border;\n  bottom?: Border;\n  left?: Border;\n}\n\nexport interface TableBorders extends BoxBorders {\n  insideH?: Border;\n  insideV?: Border;\n}\n\nexport interface BoxSides {\n  top?: number;\n  right?: number;\n  bottom?: number;\n  left?: number;\n}\n\nexport interface Run {\n  text: string;\n  bold?: boolean;\n  italic?: boolean;\n  underline?: boolean;\n  strike?: boolean;\n  code?: boolean;\n  smallCaps?: boolean;\n  link?: string;\n  color?: string;\n  highlight?: string;\n  size?: number;\n  font?: string;\n  letterSpacing?: number;\n}\n\nexport interface ParagraphProps {\n  align?: Align;\n  spaceBefore?: number;\n  spaceAfter?: number;\n  lineHeight?: number;\n  indentLeft?: number;\n  indentRight?: number;\n  shading?: string;\n  borders?: BoxBorders;\n}\n\nexport interface ListItem extends ParagraphProps {\n  runs: Run[];\n  children?: ListItem[];\n}\n\nexport interface TableCell {\n  blocks: Block[];\n  shading?: string;\n  borders?: BoxBorders;\n  padding?: BoxSides;\n  valign?: VAlign;\n  colSpan?: number;\n  rowSpan?: number;\n}\n\nexport interface TableRow {\n  header?: boolean;\n  cells: TableCell[];\n}\n\nexport type Block =\n  | ({ type: \"heading\"; level: number; runs: Run[] } & ParagraphProps)\n  | ({ type: \"paragraph\"; runs: Run[] } & ParagraphProps)\n  | { type: \"list\"; ordered?: boolean; items: ListItem[] }\n  | {\n      type: \"table\";\n      rows: TableRow[];\n      widths?: number[];\n      width?: number;\n      align?: \"left\" | \"center\" | \"right\";\n      borders?: TableBorders;\n      cellPadding?: BoxSides;\n    }\n  | { type: \"code\"; language?: string; text: string }\n  | { type: \"quote\"; blocks: Block[] }\n  | { type: \"image\"; src: string; widthPx?: number; heightPx?: number; alt?: string }\n  | { type: \"pageBreak\" }\n  | { type: \"hr\" }\n  | {\n      type: \"chart\";\n      \/** An Apache ECharts option, as last-word 0.10 writes it (the same field dark-slide charts take). *\/\n      option: Record<string, unknown>;\n      widthPx?: number;\n      heightPx?: number;\n      \/** Percentage of the text column. *\/\n      width?: number;\n      numberFormat?: string;\n      alt?: string;\n    };\n\n\/** A running header or footer: three slots; `{page}` and `{pages}` are filled in. *\/\nexport interface Band {\n  left?: string;\n  center?: string;\n  right?: string;\n  \/** Points. Default 9. *\/\n  size?: number;\n  color?: string;\n}\n\nexport interface Page {\n  size?: \"letter\" | \"legal\" | \"a4\";\n  orientation?: \"portrait\" | \"landscape\";\n  margins?: BoxSides;\n  header?: Band;\n  footer?: Band;\n}\n\nexport interface Doc {\n  title?: string;\n  blocks: Block[];\n  page?: Page;\n  defaultFont?: string;\n  defaultSize?: number;\n}\n","type":"registry:ui","target":"components\/fancy\/doc-viewer\/types.ts"},{"path":"components\/fancy\/doc-viewer\/style.ts","content":"import type { CSSProperties } from \"react\";\nimport type { Border, BoxBorders, BoxSides, ParagraphProps, Run } from \".\/types\";\n\n\/**\n * What last-word's writer gives a document that asks for nothing, so an\n * unstyled document looks here as it does in Word. Read off the writer's\n * styles part (docDefaults, Heading1-6, Quote, CodeBlock, Hyperlink) rather\n * than guessed.\n *\/\nexport const DEFAULTS = {\n  font: \"Calibri\",\n  \/** Points. *\/\n  size: 11,\n  \/** w:spacing after=\"160\": 8pt after every paragraph. *\/\n  spaceAfter: 8,\n  \/** w:line=\"259\" of 240: 1.08. *\/\n  lineHeight: 1.08,\n  \/** Heading 1-6, points. *\/\n  headingSizes: [20, 16, 14, 13, 12, 11],\n  headingSpaceBefore: 12,\n  headingSpaceAfter: 6,\n  quoteIndent: 36,\n  quoteColor: \"#595959\",\n  codeFont: \"Consolas\",\n  codeSize: 10,\n  codeFill: \"#F2F2F2\",\n  linkColor: \"#0563C1\",\n  \/** Cell margins 60 \/ 108 twips. *\/\n  cellPadding: { top: 3, right: 5.4, bottom: 3, left: 5.4 },\n  \/** The header-cell grey the writer and all three engines share. *\/\n  headerFill: \"#E7E7E7\",\n  \/** A border edge that names no width. *\/\n  borderWidth: 0.5,\n  \/** Page margins when none are given: 1440 twips. *\/\n  margin: 72,\n  \/** Header \/ footer distance from the page edge: 720 twips. *\/\n  bandDistance: 36,\n  bandSize: 9,\n};\n\n\/** Page sizes in points, portrait. *\/\nexport const PAGE_SIZES: Record<string, [number, number]> = {\n  letter: [612, 792],\n  legal: [612, 1008],\n  a4: [595, 842],\n};\n\n\/**\n * `#RRGGBB` (or `RRGGBB`), or nothing. A colour comes out of a file, so anything\n * else -- a CSS expression smuggled into a colour field -- is dropped rather\n * than handed to a style.\n *\/\nexport function hex(value: unknown): string | undefined {\n  if (typeof value !== \"string\") return undefined;\n  const v = value.trim();\n  if (\/^#[0-9a-fA-F]{6}$\/.test(v)) return v.toUpperCase();\n  if (\/^[0-9a-fA-F]{6}$\/.test(v)) return `#${v.toUpperCase()}`;\n  return undefined;\n}\n\n\/** A finite number, or nothing. *\/\nexport function num(value: unknown): number | undefined {\n  return typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n}\n\n\/** A font name as a CSS family list: quoted, with a generic fallback. *\/\nexport function fontStack(font: unknown, generic = \"sans-serif\"): string | undefined {\n  if (typeof font !== \"string\" || font.trim() === \"\") return undefined;\n  const name = font.replace(\/[\"\\\\;{}<>]\/g, \"\").trim();\n  return `\"${name}\", ${generic}`;\n}\n\nconst CSS_STYLE: Record<string, string> = { single: \"solid\", double: \"double\", dashed: \"dashed\", dotted: \"dotted\" };\n\n\/** One border edge as CSS. `style: \"none\"` removes the edge. *\/\nexport function borderCss(border: Border | undefined): string | undefined {\n  if (!border) return undefined;\n  if (border.style === \"none\") return \"none\";\n  const width = num(border.width) ?? DEFAULTS.borderWidth;\n  const style = CSS_STYLE[border.style ?? \"single\"] ?? \"solid\";\n  return `${width}pt ${style} ${hex(border.color) ?? \"#000000\"}`;\n}\n\nexport function boxBorders(borders: BoxBorders | undefined): CSSProperties {\n  if (!borders) return {};\n  const out: CSSProperties = {};\n  const top = borderCss(borders.top);\n  const right = borderCss(borders.right);\n  const bottom = borderCss(borders.bottom);\n  const left = borderCss(borders.left);\n  if (top) out.borderTop = top;\n  if (right) out.borderRight = right;\n  if (bottom) out.borderBottom = bottom;\n  if (left) out.borderLeft = left;\n  return out;\n}\n\nexport function padding(sides: BoxSides | undefined, fallback?: BoxSides): CSSProperties {\n  const pick = (k: keyof BoxSides) => num(sides?.[k]) ?? num(fallback?.[k]);\n  const out: CSSProperties = {};\n  const t = pick(\"top\");\n  const r = pick(\"right\");\n  const b = pick(\"bottom\");\n  const l = pick(\"left\");\n  if (t !== undefined) out.paddingTop = `${t}pt`;\n  if (r !== undefined) out.paddingRight = `${r}pt`;\n  if (b !== undefined) out.paddingBottom = `${b}pt`;\n  if (l !== undefined) out.paddingLeft = `${l}pt`;\n  return out;\n}\n\n\/**\n * Word's \"single\" line is the font's own line height -- ascent + descent + gap,\n * about 1.2 em for Calibri and most text faces -- where CSS's 1.0 is the bare\n * em. So a document's 1.08 lines is 1.08 x 1.2 here; set as 1.08 it drew every\n * paragraph visibly tighter than LibreOffice's render of the same file.\n *\/\nexport const SINGLE_LINE = 1.2;\n\nexport function lineHeightCss(lines: number): number {\n  return Math.round(lines * SINGLE_LINE * 1000) \/ 1000;\n}\n\n\/** A paragraph-shaped block's box: alignment, spacing, indents, shading, borders. *\/\nexport function paragraphCss(p: ParagraphProps, defaults: { before?: number; after?: number } = {}): CSSProperties {\n  const out: CSSProperties = {\n    marginTop: `${num(p.spaceBefore) ?? defaults.before ?? 0}pt`,\n    marginBottom: `${num(p.spaceAfter) ?? defaults.after ?? DEFAULTS.spaceAfter}pt`,\n    lineHeight: String(lineHeightCss(num(p.lineHeight) ?? DEFAULTS.lineHeight)),\n    ...boxBorders(p.borders),\n  };\n  if (p.align) out.textAlign = p.align;\n  const left = num(p.indentLeft);\n  const right = num(p.indentRight);\n  if (left !== undefined) out.marginLeft = `${left}pt`;\n  if (right !== undefined) out.marginRight = `${right}pt`;\n  const fill = hex(p.shading);\n  if (fill) {\n    out.backgroundColor = fill;\n    \/\/ Word draws the shading behind the text with a little room; without it\n    \/\/ the fill hugs the glyphs and a callout reads as a highlight.\n    out.padding = \"2pt 4pt\";\n  }\n  return out;\n}\n\n\/** A run's character style. *\/\nexport function runCss(run: Run): CSSProperties {\n  const out: CSSProperties = {};\n  if (run.bold) out.fontWeight = 700;\n  if (run.italic) out.fontStyle = \"italic\";\n  const lines = [run.underline ? \"underline\" : \"\", run.strike ? \"line-through\" : \"\"].filter(Boolean);\n  if (lines.length) out.textDecorationLine = lines.join(\" \");\n  const color = hex(run.color);\n  if (color) out.color = color;\n  const highlight = hex(run.highlight);\n  if (highlight) out.backgroundColor = highlight;\n  const size = num(run.size);\n  if (size !== undefined && size > 0) out.fontSize = `${size}pt`;\n  const font = fontStack(run.font);\n  if (font) out.fontFamily = font;\n  if (run.smallCaps) out.fontVariant = \"small-caps\";\n  const spacing = num(run.letterSpacing);\n  if (spacing) out.letterSpacing = `${spacing}pt`;\n  return out;\n}\n","type":"registry:ui","target":"components\/fancy\/doc-viewer\/style.ts"},{"path":"components\/fancy\/doc-viewer\/ChartBlock.tsx","content":"import { lazy, Suspense, type ComponentType, type CSSProperties } from \"react\";\nimport { DEFAULTS, num } from \".\/style\";\n\nexport interface ChartBlockData {\n  type: \"chart\";\n  \/** An Apache ECharts option -- the same field a dark-slide chart element takes. *\/\n  option: Record<string, unknown>;\n  \/** Pixels. *\/\n  widthPx?: number;\n  heightPx?: number;\n  \/** Percentage of the text column. *\/\n  width?: number;\n  \/** Excel number format for the chart's values. *\/\n  numberFormat?: string;\n  alt?: string;\n}\n\ninterface InnerProps {\n  option: unknown;\n}\n\n\/**\n * fancy-echarts is an OPTIONAL peer: a document without a chart must not make\n * an app install a charting engine. So it is loaded on first use, and an app\n * without it gets a labelled placeholder rather than a build failure -- the\n * pattern fancy-slides' chart host uses.\n *\/\nconst ChartInner: ComponentType<InnerProps> = lazy(async () => {\n  try {\n    const mod: Record<string, unknown> = await import(\"@particle-academy\/fancy-echarts\");\n    const EChart = mod.EChart as ComponentType<{ option: unknown; style?: CSSProperties }> | undefined;\n    if (!EChart) return { default: Missing };\n    (mod.registerAll as (() => void) | undefined)?.();\n    return {\n      default: ({ option }: InnerProps) => <EChart option={option} style={{ width: \"100%\", height: \"100%\" }} \/>,\n    };\n  } catch {\n    return { default: Missing };\n  }\n});\n\nfunction Missing() {\n  return (\n    <div style={{ display: \"flex\", height: \"100%\", alignItems: \"center\", justifyContent: \"center\", border: \"0.5pt dashed #9CA3AF\", color: \"#6B7280\", fontSize: \"9pt\" }}>\n      Chart (install @particle-academy\/fancy-echarts to draw it)\n    <\/div>\n  );\n}\n\n\/**\n * The option laid out for a page: transparent behind it (the page shows\n * through, as in Word) and a grid that keeps axis labels inside the box.\n * Defaults only -- an option that sets its own keeps it.\n *\/\nexport function pageChartOption(option: Record<string, unknown>): Record<string, unknown> {\n  const isObj = (v: unknown): v is Record<string, unknown> => typeof v === \"object\" && v !== null && !Array.isArray(v);\n  const hasAxes = option.xAxis !== undefined || option.yAxis !== undefined;\n  const xAxis = isObj(option.xAxis) ? option.xAxis : undefined;\n  const yAxis = isObj(option.yAxis) ? option.yAxis : undefined;\n  \/\/ Bars run sideways when the CATEGORY axis is y.\n  const horizontal = (yAxis?.type ?? \"value\") === \"category\";\n\n  \/\/ Legend below the plot, as Word places it; only when the file did not.\n  const legendShown = isObj(option.legend) && option.legend.show !== false;\n  const legend =\n    isObj(option.legend) && legendShown && option.legend.top === undefined && option.legend.bottom === undefined\n      ? { ...option.legend, bottom: 0 }\n      : option.legend;\n\n  \/\/ Axis titles centred along their axis, as Word draws them. ECharts puts a\n  \/\/ name at the END of the axis, where a long one is clipped.\n  const named = (axis: Record<string, unknown> | undefined, extra: Record<string, unknown>) =>\n    axis && typeof axis.name === \"string\" && axis.name !== \"\" && axis.nameLocation === undefined\n      ? { ...axis, nameLocation: \"middle\", ...extra }\n      : axis;\n\n  \/\/ Values printed past the end of the bar or above the point (outEnd), as\n  \/\/ Word prints them; ECharts' default puts a bar's value inside it.\n  const series = Array.isArray(option.series)\n    ? option.series.map((s) => {\n        if (!isObj(s) || !isObj(s.label) || s.label.show !== true || s.label.position !== undefined) return s;\n        if (s.type === \"bar\") return { ...s, label: { ...s.label, position: horizontal ? \"right\" : \"top\" } };\n        if (s.type === \"line\") return { ...s, label: { ...s.label, position: \"top\" } };\n        return s;\n      })\n    : option.series;\n\n  return {\n    backgroundColor: \"transparent\",\n    \/\/ The palette last-word's writer uses when a chart names no colours, so an\n    \/\/ unstyled chart is the colour it is in Word, not ECharts' own.\n    color: OFFICE_PALETTE,\n    ...option,\n    ...(legend === undefined ? {} : { legend }),\n    ...(series === undefined ? {} : { series }),\n    \/\/ nameMoveOverlap (ECharts 6) measures the gap from the axis LABELS, so a\n    \/\/ long category label never ends up under the title; outerBoundsContain\n    \/\/ 'all' makes the plot area leave room for labels and titles alike.\n    ...(xAxis ? { xAxis: named(xAxis, { nameGap: 10, nameMoveOverlap: true }) } : {}),\n    ...(yAxis ? { yAxis: named(yAxis, { nameGap: 10, nameRotate: 90, nameMoveOverlap: true }) } : {}),\n    ...(hasAxes\n      ? {\n          grid: option.grid ?? {\n            top: option.title ? 36 : 16,\n            right: 16,\n            bottom: 8 + (legendShown ? 28 : 0),\n            left: 8,\n            outerBoundsMode: \"same\",\n            outerBoundsContain: \"all\",\n          },\n        }\n      : {}),\n  };\n}\n\n\/** last-word's default chart palette (Office's): 4472C4, ED7D31, A5A5A5, \u2026 *\/\nconst OFFICE_PALETTE = [\"#4472C4\", \"#ED7D31\", \"#A5A5A5\", \"#FFC000\", \"#5B9BD5\", \"#70AD47\", \"#264478\", \"#9E480E\"];\n\n\/** Whether the block has anything to plot. ECharts draws an empty box for an option with no series. *\/\nexport function isPlottable(block: ChartBlockData): boolean {\n  const option = block?.option;\n  if (!option || typeof option !== \"object\") return false;\n  const series = (option as { series?: unknown }).series;\n  return Array.isArray(series) ? series.length > 0 : series !== undefined && series !== null;\n}\n\n\/**\n * A chart block, sized as the writer sizes it: `widthPx` \/ `heightPx` when given,\n * a `width` percentage of the column, or the full column at 0.6 of its width tall.\n *\/\nexport function ChartBlock({ block }: { block: ChartBlockData }) {\n  const widthPx = num(block.widthPx);\n  const heightPx = num(block.heightPx);\n  const percent = num(block.width);\n\n  const style: CSSProperties = {\n    width: widthPx !== undefined ? `${widthPx}px` : percent !== undefined ? `${percent}%` : \"100%\",\n    maxWidth: \"100%\",\n    margin: `0 0 ${DEFAULTS.spaceAfter}pt`,\n  };\n  if (heightPx !== undefined) style.height = `${heightPx}px`;\n  else style.aspectRatio = \"5 \/ 3\";\n\n  return (\n    <div data-fancy-docs-block=\"chart\" role=\"img\" aria-label={typeof block.alt === \"string\" ? block.alt : undefined} style={style}>\n      <Suspense fallback={null}>\n        <ChartInner option={pageChartOption(block.option)} \/>\n      <\/Suspense>\n    <\/div>\n  );\n}\n","type":"registry:ui","target":"components\/fancy\/doc-viewer\/ChartBlock.tsx"}]}