"""Result serialisation for document-mode QC. Writes both a structured JSON file (full per-check + per-page drilldown) and a self-contained HTML report optimised for fast triage: • Top "Findings at a glance" panel — one line per check • Per-check sections with structured findings tables • Per-page accordion (collapsed by default; "show only failing" filter) Filename convention mirrors single-asset mode: /__data.json /__report.html """ import html import json import os from typing import Dict, List, Optional def _slugify_filename(name: str) -> str: base = os.path.splitext(os.path.basename(name))[0] return base.replace(' ', '_').replace('/', '_') def _score_class(score: float) -> str: if score >= 8: return 'score-good' if score >= 6: return 'score-ok' return 'score-bad' def _pill(text: str, kind: str = 'neutral') -> str: classes = { 'good': 'pill pill-good', 'ok': 'pill pill-ok', 'bad': 'pill pill-bad', 'neutral': 'pill', } return f'{html.escape(str(text))}' # ───────────────────────────────────────────────────────────────────────────── # Findings renderers — each understands a specific check's structured payload # ───────────────────────────────────────────────────────────────────────────── def _render_font_inventory(findings: Dict) -> str: distribution = findings.get('distribution') or [] if not distribution: return '

No fonts captured.

' rows = ''.join( f"{html.escape(d['font'])}{d['page_count']}" f"{html.escape(', '.join(str(p) for p in d['pages'][:30]))}{'…' if len(d['pages']) > 30 else ''}" for d in distribution ) return f""" {rows}
FontPages with this fontPage list (first 30)
""" def _render_phone_inventory(findings: Dict) -> str: distribution = findings.get('distribution') or [] if not distribution: return '

No phone numbers detected.

' rows = ''.join( f"{html.escape(d['number'])}{d['occurrences']}" f"{html.escape(', '.join(str(p) for p in d['pages'][:30]))}{'…' if len(d['pages']) > 30 else ''}" for d in distribution ) return f""" {rows}
NumberOccurrencesPages
""" def _render_bold_words_violations(findings: Dict) -> str: if findings.get('error') == 'seed_missing': return '

No bold-words seed dictionary present.

' violations = findings.get('violations') or [] bold_n = findings.get('bold_occurrences', 0) non_bold_n = findings.get('non_bold_occurrences', 0) excluded = findings.get('definitions_pages_excluded') or [] dict_size = findings.get('dictionary_size', 0) head = f"""

{dict_size} defined terms scanned · {bold_n} correctly bold · {non_bold_n} non-bold · excluding definitions pages {excluded or '(none)'}.

""" if not violations: return head + '

No non-bold occurrences detected.

' # Group violations by term for readability by_term: Dict[str, List[Dict]] = {} for v in violations: by_term.setdefault(v['term'], []).append(v) sections = [] for term, items in sorted(by_term.items(), key=lambda kv: -len(kv[1])): rows = ''.join( f"{v['page']}{html.escape((v['context'] or '')[:200])}" f"{html.escape(v.get('font') or '?')}" for v in items[:25] ) more = f"…and {len(items) - 25} more occurrences." if len(items) > 25 else '' sections.append(f"""
{html.escape(term)} — {len(items)} non-bold occurrence{'s' if len(items) != 1 else ''} {rows}{more}
PageContext (truncated)Font
""") return head + '\n'.join(sections) def _render_page_numbering(findings: Dict) -> str: issues = findings.get('discontinuities') or [] detected = findings.get('pages_with_detected_number', 0) total = findings.get('pages_total', 0) head = f"

Detected page numbers on {detected}/{total} pages.

" if not issues: return head + '

No discontinuities detected.

' rows = ''.join( f"{i['page_index']}{i['expected']}{i['detected']}" for i in issues ) return head + f""" {rows}
Page indexExpectedDetected

Heuristic — TOC pages and section dividers can produce false positives.

""" def _render_print_code(findings: Dict) -> str: pages = findings.get('pages_inspected') or [] code = findings.get('code_candidates') or [] refs = findings.get('doc_refs') or [] dates = findings.get('date_candidates') or [] versions = findings.get('version_candidates') or [] return f"""

Inspected page(s): {pages}

Code candidates: {', '.join(code) or 'none'}
Document refs: {', '.join(refs) or 'none'}
Date candidates: {', '.join(dates) or 'none'}
Version candidates: {', '.join(versions) or 'none'}

""" def _render_omg_versioning(findings: Dict) -> str: pages = findings.get('pages_inspected') or [] omg = findings.get('omg_matches') or [] dates = findings.get('date_matches') or [] return f"""

Inspected page(s): {pages}

OMG codes: {', '.join(omg) or 'none'}
Date formats: {', '.join(dates) or 'none'}

""" def _render_print_preflight(findings: Dict) -> str: if findings.get('error'): return f"

Error: {html.escape(str(findings['error']))}

" criteria = findings.get('criteria') or [] passed = findings.get('criteria_passed', 0) total = findings.get('criteria_total', 0) head = f"

{passed} / {total} print preflight criteria passed.

" rows = [] for c in criteria: marker = '' if c['passed'] else '' detail_extra = '' d = c.get('detail') or {} if d.get('low_dpi_images'): items = ''.join( f"
  • Page {x['page']}, xref {x['xref']}: {x['effective_dpi']} DPI " f"(rendered {x['rendered_inches'][0]} × {x['rendered_inches'][1]} in)
  • " for x in d['low_dpi_images'][:10] ) more = (f"
  • …and {len(d['low_dpi_images']) - 10} more.
  • " if len(d['low_dpi_images']) > 10 else '') detail_extra = f"
      {items}{more}
    " elif d.get('colorspace_counts'): cs = d['colorspace_counts'] cs_summary = ', '.join(f"{k}: {v}" for k, v in sorted(cs.items())) detail_extra = f"
    {html.escape(cs_summary)}" elif d.get('spot_spaces'): detail_extra = f"
    {html.escape(', '.join(d['spot_spaces']))}" elif d.get('distinct_sizes_pts'): sizes = '; '.join(f"{round(s[0]*0.3528,1)}×{round(s[1]*0.3528,1)}mm" for s in d['distinct_sizes_pts']) detail_extra = f"
    {html.escape(sizes)}" rows.append(f""" {marker} {html.escape(c['code'])} {html.escape(c['title'])} {html.escape(c['note'])}{detail_extra} """) return head + f""" {''.join(rows)}
    CodeCriterionObservation
    """ def _render_pdf_accessibility(findings: Dict) -> str: if findings.get('error'): return f"

    Error: {html.escape(str(findings['error']))}

    " criteria = findings.get('criteria') or [] passed = findings.get('criteria_passed', 0) total = findings.get('criteria_total', 0) verapdf_run = findings.get('verapdf_run', False) verapdf = findings.get('verapdf') or {} if verapdf_run: verapdf_label = 'enabled' elif verapdf.get('error'): verapdf_label = f'error: {html.escape(verapdf["error"])}' else: verapdf_label = 'not installed on host' head = f"""

    {passed} / {total} fast criteria passed · veraPDF PDF/UA-1: {verapdf_label}

    """ verapdf_block = '' if verapdf_run: compliant = verapdf.get('compliant') verdict_html = ( "COMPLIANT" if compliant else "NOT COMPLIANT" ) rule_rows = [] for r in verapdf.get('failed_rule_details') or []: tags = ', '.join(r.get('tags') or []) or '—' samples = r.get('sample_errors') or [] sample_html = '' if samples: sample_html = ( "
    e.g. " + html.escape(samples[0]) + "" ) rule_rows.append(f""" {html.escape(str(r.get('clause', '')))}-{html.escape(str(r.get('test_number', '')))} {r.get('failed_checks', 0)} {html.escape(tags)} {html.escape(r.get('description', ''))}{sample_html} """) verapdf_block = f"""

    veraPDF verdict: {verdict_html} · {verapdf.get('passed_rules', 0)} rules passed / {verapdf.get('failed_rules', 0)} failed · {verapdf.get('passed_checks', 0)} checks passed / {verapdf.get('failed_checks', 0)} failed

    """ if rule_rows: verapdf_block += f""" {''.join(rule_rows)}
    ClauseFailuresTagsDescription
    """ rows = [] for c in criteria: marker = '' if c['passed'] else '' detail_extra = '' d = c.get('detail') or {} if d.get('not_embedded'): detail_extra = f"
    Non-embedded: {html.escape(', '.join(d['not_embedded']))}" elif d.get('image_count') is not None: detail_extra = f"
    {d.get('image_count', 0)} images on {d.get('pages_with_images', 0)} pages (first 30)" rows.append(f""" {marker} {html.escape(c['code'])} {html.escape(c['title'])} {html.escape(c['note'])}{detail_extra} """) return head + verapdf_block + f""" {''.join(rows)}
    CodeCriterionObservation
    """ def _render_generic(findings: Dict, response: str) -> str: """Fallback renderer for checks without a custom structured view — just show the response as preformatted text.""" if response: return f"
    {html.escape(response)}
    " return f"
    {html.escape(json.dumps(findings, indent=2, default=str))}
    " # Human-readable labels for page_type tags. Kept in sync with # document_mode.page_classifier.PAGE_TYPE_LABELS. _PAGE_TYPE_LABELS = { 'cover': 'Cover', 'checklist': 'Asset Checklist', 'palette': 'Creative Guidance', 'notes': 'Yellow Notes', 'artwork': 'Artwork', } def _page_type_pill(page_type: str) -> str: label = _PAGE_TYPE_LABELS.get(page_type, page_type or 'artwork') cls = 'page-type-artwork' if page_type == 'artwork' else 'page-type-info' return f"{html.escape(label)}" def _render_page_each(findings: Dict, response: str) -> str: """Per-page breakdown for any page_each-scope check. Renders a table of pages (page_num, type, score, status) followed by expandable per-page response cards. Used by the Boots PPack profile where every check runs on every page. """ page_scores = findings.get('page_scores') or {} page_types = findings.get('page_types') or {} page_responses = findings.get('page_responses') or {} artwork_scores = findings.get('artwork_page_scores') or {} informational_scores = findings.get('informational_page_scores') or {} failing_artwork = set(findings.get('failing_artwork_pages') or []) if not page_scores: # Fall back to the generic response dump if the dispatcher didn't # populate per-page data (e.g. a check raised mid-run). return _render_generic(findings, response) # Headline summary line head = f"""

    Ran on {len(page_scores)} pages — {len(artwork_scores)} artwork, {len(informational_scores)} informational (informational pages don't affect Pass/Fail).

    """ rows = [] for page_num, score in sorted(page_scores.items()): ptype = page_types.get(page_num, 'artwork') is_artwork = ptype == 'artwork' score_cls = _score_class(score) if is_artwork: status_pill = ( 'Below threshold' if page_num in failing_artwork else 'OK' ) else: status_pill = 'Informational' response_text = page_responses.get(page_num, '') body = ( f"
    Show details" f"
    {html.escape(response_text)}
    " f"
    " if response_text else '' ) rows.append(f""" Page {page_num} {_page_type_pill(ptype)} {score} {status_pill} {body} """) return head + f""" {''.join(rows)}
    PagePage typeScoreStatusDetail
    """ _FINDINGS_RENDERERS = { 'axa_font_inventory': _render_font_inventory, 'axa_phone_inventory': _render_phone_inventory, 'axa_bold_words_definitions': _render_bold_words_violations, 'axa_page_numbering': _render_page_numbering, 'axa_print_code': _render_print_code, 'axa_omg_versioning': _render_omg_versioning, 'axa_pdf_accessibility': _render_pdf_accessibility, 'axa_print_preflight': _render_print_preflight, } # ───────────────────────────────────────────────────────────────────────────── # Main HTML report # ───────────────────────────────────────────────────────────────────────────── def _render_check_section(check_name: str, check_result: Dict) -> str: score = check_result.get('score', 0) or 0 summary = check_result.get('summary', '') findings = check_result.get('findings', {}) or {} response = check_result.get('response', '') or '' scope = check_result.get('scope', '?') pass_flag = check_result.get('pass', False) renderer = _FINDINGS_RENDERERS.get(check_name) if renderer: body = renderer(findings) elif scope == 'page_each': body = _render_page_each(findings, response) else: body = _render_generic(findings, response) return f"""
    {html.escape(check_name)} scope: {html.escape(scope)} {score} {('Pass' if pass_flag else 'Fail')}

    {html.escape(summary)}

    {body}
    """ def _render_at_a_glance(check_summaries: Dict[str, Dict]) -> str: rows = [] for name, s in check_summaries.items(): score = s.get('score', 0) or 0 rows.append(f""" {html.escape(name)} {html.escape(s.get('scope', '?'))} {score} {('Pass' if s.get('pass') else 'Fail')} {html.escape(s.get('summary', ''))} """) return f""" {''.join(rows)}
    CheckScopeScoreStatusHeadline finding
    """ def _render_page_strip(pages: List[Dict]) -> str: """Per-page strip showing fonts found on each page (and image link if available).""" if not pages: return '' rows = [] for p in pages: fonts = p.get('fonts_used') or [] ptype = p.get('page_type', 'artwork') rows.append(f"""
    Page {p['page_num']} {_page_type_pill(ptype)} {len(fonts)} fonts

    {html.escape(', '.join(fonts))}

    """) return ''.join(rows) def _render_html(result: Dict, original_filename: str) -> str: summary = result.get('document_summary', {}) overall_score = summary.get('overall_score', 0) grade = summary.get('grade', '') check_summaries = summary.get('check_summaries', {}) check_results = result.get('check_results', {}) pages = result.get('pages', []) fonts_inventory = (result.get('ingest_metadata') or {}).get('fonts_inventory', []) truncated_banner = '' if result.get('truncated'): truncated_banner = f""" """ strict_banner = '' if summary.get('strict_grade'): violations = summary.get('strict_violations') or [] if violations: # Group violations by page for readability by_page: Dict[int, List[Dict]] = {} for v in violations: by_page.setdefault(v['page'], []).append(v) page_rows = [] for page_num, vs in sorted(by_page.items()): check_list = ', '.join( f"{html.escape(v['check'])} ({v['score']})" for v in vs ) page_rows.append(f"
  • Page {page_num}: {check_list}
  • ") strict_banner = f""" """ else: strict_banner = """ """ glance = _render_at_a_glance(check_summaries) check_sections = '\n'.join( f"{_render_check_section(name, check_results.get(name, {}))}" for name in check_summaries.keys() ) fonts_pill_strip = ''.join( f"{html.escape(f)}" for f in fonts_inventory ) or 'No fonts captured.' page_strip = _render_page_strip(pages) return f""" QC Report — {html.escape(original_filename)}

    QC Report — {html.escape(original_filename)}

    Profile: {html.escape(result.get('profile_name', ''))} · Pages processed: {result.get('pages_processed', 0)} / {result.get('page_count', 0)} · {html.escape(result.get('timestamp', ''))}
    {truncated_banner} {strict_banner}
    {overall_score}
    Overall score (0-100)
    {grade}

    Findings at a glance

    {glance}

    Check details

    {check_sections}

    Fonts inventory

    {fonts_pill_strip}

    Per-page summary

    {page_strip}
    """ # ───────────────────────────────────────────────────────────────────────────── # Public entrypoint # ───────────────────────────────────────────────────────────────────────────── def write_document_report( result: Dict, original_filename: str, session_id: str, output_dir: str, output_mode: str = 'both', ) -> Dict[str, Optional[str]]: """Write JSON + HTML reports for a document-mode analysis. Args: result: aggregated dict from dispatcher.run_document_analysis(). original_filename: source PDF filename (for naming + display). session_id: session id, used as the filename prefix. output_dir: pre-created client-scoped output directory. output_mode: 'json', 'html', or 'both'. Returns: { 'json': path or None, 'html': path or None } """ os.makedirs(output_dir, exist_ok=True) slug = _slugify_filename(original_filename) paths: Dict[str, Optional[str]] = {'json': None, 'html': None} if output_mode in ('json', 'both'): json_path = os.path.join(output_dir, f"{session_id}_{slug}_data.json") with open(json_path, 'w', encoding='utf-8') as f: json.dump(result, f, indent=2, default=str) paths['json'] = json_path if output_mode in ('html', 'both'): html_path = os.path.join(output_dir, f"{session_id}_{slug}_report.html") html_doc = _render_html(result, original_filename) with open(html_path, 'w', encoding='utf-8') as f: f.write(html_doc) paths['html'] = html_path return paths