use std::path::PathBuf;
use clap::Parser;
use claria_docx::{
DiagnosedParagraph, MAX_TEMPLATE_DOCX_BYTES, SectioningVerdict, TemplateDiagnosis,
analyze_template,
};
use eyre::{Context, bail};
#[derive(Parser)]
#[command(
name = "claria-docx",
about = "Report how Claria's importer classifies a .docx template"
)]
struct Args {
path: PathBuf,
#[arg(long)]
paragraphs: bool,
#[arg(long)]
styles: bool,
#[arg(long)]
infer: bool,
}
fn main() -> eyre::Result<()> {
let args = Args::parse();
let bytes =
std::fs::read(&args.path).wrap_err_with(|| format!("reading {}", args.path.display()))?;
if bytes.len() as u64 > MAX_TEMPLATE_DOCX_BYTES {
bail!(
"{} is {} bytes; Claria refuses templates over {MAX_TEMPLATE_DOCX_BYTES} bytes",
args.path.display(),
bytes.len()
);
}
let diagnosis = analyze_template(&bytes).wrap_err("importing the template")?;
report(&args, &diagnosis);
Ok(())
}
fn report(args: &Args, diagnosis: &TemplateDiagnosis) {
let stats = &diagnosis.stats;
println!("{}", args.path.display());
println!(" sha256 {}", diagnosis.source_sha256);
println!(" title {}", diagnosis.title);
println!(
" content {} paragraphs, {} bullet lists, {} tables, {} placeholders",
stats.paragraphs, stats.bullet_lists, stats.tables, stats.placeholder_count
);
println!(
" sections {} ({}{})",
diagnosis.sections.len(),
diagnosis.verdict.as_str(),
if diagnosis.sections_inferred {
"; split by formatting"
} else {
""
}
);
println!("\nSections");
for (ordinal, section) in diagnosis.sections.iter().enumerate() {
let synthetic = if section.synthetic {
" <- invented; no paragraph carried a heading style"
} else {
""
};
println!(
" {:>3}. {} [{} blocks, {} chars]{synthetic}",
ordinal + 1,
section.heading,
section.blocks,
section.characters
);
}
let heading_styles: Vec<_> = diagnosis.heading_styles().collect();
println!("\nStyles that would start a section");
if heading_styles.is_empty() {
println!(" (none — nothing in this package resolves to a heading style)");
} else {
for style in heading_styles {
let because = style.because.as_deref().unwrap_or("");
println!(
" {:<28} {:<24} used by {:>4} paragraphs {because}",
style.name, style.style_id, style.used_by
);
}
}
if args.styles {
println!("\nEvery declared paragraph style");
for style in &diagnosis.styles {
let verdict = style
.verdict
.map(|verdict| verdict.as_str())
.unwrap_or("body");
let based_on = style
.based_on
.as_deref()
.map(|base| format!(" basedOn={base}"))
.unwrap_or_default();
let outline = if style.outline_level {
" outlineLvl"
} else {
""
};
println!(
" {:<28} {:<24} {:<8} used by {:>4}{based_on}{outline}",
style.name, style.style_id, verdict, style.used_by
);
}
}
if args.infer {
let inferred = diagnosis.inferred_sectioning();
println!(
"\nAppearance fallback: {} headings, {:.0}% of paragraphs, {}",
inferred.headings.len(),
inferred.density * 100.0,
inferred.rejected_because.unwrap_or("would run")
);
for heading in &inferred.headings {
println!(" {:>4} {}", heading.index, heading.preview);
}
}
let outlined = diagnosis
.paragraphs
.iter()
.filter(|paragraph| paragraph.claims_outline_level())
.count();
if outlined > 0 {
println!("\nParagraphs carrying their own outline level: {outlined}");
}
let missed: Vec<&DiagnosedParagraph> = diagnosis.missed_headings().collect();
if !missed.is_empty() {
println!(
"\nParagraphs that read as headings but carry no heading style ({})",
missed.len()
);
for paragraph in &missed {
println!(
" {:>4} {:<20} {}",
paragraph.index,
signal_summary(paragraph),
paragraph.preview
);
}
}
if args.paragraphs {
println!("\nEvery paragraph");
for paragraph in &diagnosis.paragraphs {
println!(
" {:>4} {:<10} {:<24} {}",
paragraph.index,
paragraph.kind,
paragraph.style_id.as_deref().unwrap_or("(no style)"),
paragraph.preview
);
}
}
if !diagnosis.warnings.is_empty() {
println!("\nImport warnings");
for warning in &diagnosis.warnings {
println!(" {:?} x{}", warning.code, warning.count);
}
}
println!("\n{}", explanation(diagnosis));
}
fn signal_summary(paragraph: &DiagnosedParagraph) -> String {
let shape = paragraph.shape;
let mut signals = Vec::new();
if shape.all_bold {
signals.push("bold");
}
if shape.all_caps {
signals.push("caps");
}
if shape.short {
signals.push("short");
}
if shape.unpunctuated {
signals.push("no-stop");
}
signals.join("+")
}
fn explanation(diagnosis: &TemplateDiagnosis) -> String {
let missed = diagnosis.missed_headings().count();
match diagnosis.verdict {
SectioningVerdict::HeadingsFound => format!(
"Carved into {} sections from applied heading styles.",
diagnosis.stats.sections
),
SectioningVerdict::HeadingStylesDeclaredButUnused => format!(
"This package declares heading styles but no paragraph applies one, so the whole \
document imported as a single section. {missed} paragraphs read as headings by \
shape. Fix it in Word by selecting each heading and applying a Heading style from \
the styles pane."
),
SectioningVerdict::NoHeadingStyles => format!(
"Nothing in this package resolves to a heading style — no styleId starting with \
`heading`, no style named for one, no outline level — so the whole document \
imported as a single section. {missed} paragraphs read as headings by shape. Fix \
it in Word by applying a Heading style to each one."
),
}
}