///|
priv struct OfficePptxLayout {
  model : @pagelayout.PageModel
  fonts : @layout_fonts.FontRegistry
  font_substitutions : Array[String]
}

///|
/// Read an optional font catalog for slide rendering. Explicit font bytes and
/// fallback chains are shared by measurement and drawing.
async fn office_pptx_layout(
  source : OfficeReadPackage,
  matches : @argparse.Matches,
) -> OfficePptxLayout {
  let fonts = @layout_fonts.FontRegistry::new()
  let fallback : Map[String, Array[String]] = Map([])
  if matches.values.get("font-manifest") is Some([path]) {
    let document = @afs.read_file(path)
    let manifest = @json.parse(document.text())
    guard manifest is Array(entries) else {
      raise CliFailure(
        @lib.protocol_error(
          "office.invalid_arguments", "font manifest must be an array",
        ),
      )
    }
    for entry in entries {
      guard entry
        is {
          "family": String(family),
          "path": String(font_path),
          "bold": False
          | True as bold,
          "italic": False
          | True as italic,
          ..
        } else {
        raise CliFailure(
          @lib.protocol_error(
            "office.invalid_arguments", "font entry needs family, path, bold and italic",
          ),
        )
      }
      let input = @afs.open(font_path, mode=ReadOnly)
      defer input.close()
      guard input.size() <= office_read_max_package_bytes.to_int64() else {
        raise CliFailure(
          @lib.protocol_error(
            "office.pptx.resource_limit", "font program exceeds input byte limit",
          ),
        )
      }
      fonts.register(
        family,
        input.read_all().binary(),
        bold=bold is True,
        italic=italic is True,
      )
      |> ignore
      if entry is { "fallback_for": Array(families), .. } {
        for candidate in families {
          guard candidate is String(source) else {
            raise CliFailure(
              @lib.protocol_error(
                "office.invalid_arguments", "font fallback families must be strings",
              ),
            )
          }
          if !fallback.contains(source) {
            fallback[source] = []
          }
          fallback[source].push(family)
        }
      }
    }
  }
  let layout = @pagelayout_pptx.layout_presentation(
    source.bytes,
    fonts~,
    font_fallbacks=fallback,
    allow_bundled_fallbacks=true,
  ) catch {
    error =>
      raise CliFailure(
        @lib.protocol_error(
          "office.pptx.render_failed",
          "cannot lay out this presentation: \{error}",
        ),
      )
  }
  // Metrics-only bundled faces are sufficient for browser hosts with font
  // sidecars. The CLI must put those same outlines into its offline exports.
  let programs : Map[String, (@pagelayout.FontSpec, Bytes)] = Map([])
  let characters : Map[Int32, Map[Int32, Unit]] = Map([])
  fn record(run : @pagelayout.GlyphRun) {
    if !characters.contains(run.font) {
      characters[run.font] = Map([])
    }
    for char in run.text {
      characters[run.font][char.to_int()] = ()
    }
  }
  for page in layout.model.pages {
    for item in page.items {
      match item {
        Text(run) => record(run)
        Graphic(graphic) =>
          for op in graphic.ops {
            match op {
              Text(run) | PaintedText(run, _) => record(run)
              _ => ()
            }
          }
        _ => ()
      }
    }
  }
  for index, font in layout.model.fonts {
    if layout.fonts.registered_face(
        font.family,
        bold=font.bold,
        italic=font.italic,
      )
      is Some(_) {
      continue
    }
    let measured = layout.fonts.face(
      font.family,
      bold=font.bold,
      italic=font.italic,
    )
    if @fontoutlines.browser_sfnt(
        measured.family,
        bold=measured.bold,
        italic=measured.italic,
        codepoints=characters
          .get(index)
          .map(chars => chars.keys().to_array())
          .unwrap_or([]),
      )
      is Some(program) {
      programs["\{measured.family}|\{measured.bold}|\{measured.italic}"] = (
        font, program,
      )
    }
  }
  let catalog = layout.fonts.clone()
  for _, (font, program) in programs {
    catalog.register(font.family, program, bold=font.bold, italic=font.italic)
    |> ignore
  }
  {
    model: layout.model,
    fonts: catalog,
    font_substitutions: layout.font_substitutions,
  }
}

///|
async fn office_preview_render_pptx(
  source : OfficeReadPackage,
  matches : @argparse.Matches,
) -> (String, OfficePreviewStats) {
  let layout = office_pptx_layout(source, matches)
  guard layout.model.pages.length() <= office_render_max_pages else {
    raise CliFailure(
      @lib.protocol_error(
        "office.pptx.resource_limit", "presentation exceeds slide limit",
      ),
    )
  }
  let html = StringBuilder()
  html.write_string(
    "",
  )
  html.write_string(@xlsx2html.escape_html(source.file))
  html.write_string(
    "",
  )
  let mut images = 0
  for index, page in layout.model.pages {
    html.write_string(
      "
\{index+1} / \{layout.model.pages.length()}
", ) let svg = @pagelayout_svg.render_page( layout.model, index, fonts=layout.fonts, ) html.write_string( svg .strip_prefix("\n") .unwrap_or(svg) .to_owned(), ) html.write_string("
") for item in page.items { if item is Graphic(graphic) { for op in graphic.ops { if op is Image(_) { images += 1 } } } } guard html.to_string().length() <= office_preview_max_output_bytes else { raise CliFailure( @lib.protocol_error( "office.pptx.resource_limit", "preview exceeds output byte limit", ), ) } } html.write_string("") ( html.to_string(), { charts_rendered: 0, charts_placeholder: 0, truncated_sheets: [], images_embedded: images, images_omitted: 0, warnings: layout.font_substitutions.map(value => { "Font substitution: " + value }), slides_rendered: Some(layout.model.pages.length()), }, ) }