Skip to content

Follow-up bug: multi-line bottom subfigure labels are still clipped #25

Description

@ccmxigua

Summary

The PDF figure extraction pipeline can clip the lower line of multi-line subfigure labels when those labels are located at the bottom of a figure. The plotted visual body is preserved, but the extracted figure is incomplete.

This is a follow-up to Issue #17 and PR #18. Those changes introduced the Visual Review Gate and require internal labels to remain readable, but the specific multi-line bottom-label case below is not covered by the existing tests and still reproduces on the current extraction code.

Minimal synthetic reproduction

This reproducer is entirely synthetic and contains no content from any published paper:

  • one page, vector-only PDF;
  • a 4x3 grid of vector panels;
  • one-line labels under the first two rows;
  • deliberately two-line labels under the bottom row;
  • an external Figure 1 caption below the figure.

Generate the input with the generator included below:

python3 generate_synthetic_figure_case.py \
  synthetic_multiline_figure.pdf \
  --fetch-json synthetic_fetch.json

Then run the normal extractor:

python3 extract_pdf_assets.py \
  --input synthetic_fetch.json \
  --output assets.json \
  --assets-dir assets \
  --max-pages 1 \
  --min-searchable-chars 0 \
  --figure-dpi 144

Observed result

The generated crop includes the first line of each bottom-row label but omits the second line.

For the synthetic PDF, the measured coordinates are:

  • automatic crop bbox: [38.0, 86.0, 574.0, 466.99] pt;
  • first bottom-row label line: y1 = 462.99 pt;
  • second label line: y0 = 467.25, y1 = 480.99 pt.

Thus, the second line lies completely outside the automatically generated crop. Extending only the crop bottom to approximately y1 = 485 pt restores the complete labels.

The quality gate nevertheless reports the asset as usable:

  • visual_quality_status = usable;
  • visual_rect_count = 12;
  • paragraph_text_chars = 0.

This shows that the issue is reproducible without the layout or content of a real paper, and that the current quality check does not detect the missing internal text.

Expected result

All text belonging to the figure should be included, including multi-line labels at the bottom of the figure. The crop should stop before the external figure caption without cutting through or excluding an internal label line.

Likely cause

In scripts/extract_pdf_assets.py, _estimate_figure_bbox_above_caption() computes an initial visual_bbox and then tests nearby text lines against that original box. After one text line is included, the active bounding box is not updated. Consequently, a first label line may be included while the next line in the same label block is excluded.

Suggested fix

  1. Update the active bounding box after each accepted text line, or group adjacent lines into a complete text block before taking the union.
  2. Add a geometric completeness check that flags a crop when a text span intersects or lies immediately beyond the crop boundary without being fully contained.
  3. Add a regression test using this minimal synthetic PDF.

Reproduction generator

The following standalone script creates the synthetic PDF and the fetch JSON used by the extractor:

#!/usr/bin/env python3
"""Generate a minimal vector-only PDF for the DeepPaperNote crop test.

The page has a 4x3 grid of vector panels.  Only the bottom-row labels are
split over two lines; an external figure caption is placed below the figure.
This file intentionally contains no material from a published paper.
"""

from __future__ import annotations

import argparse
import json
import math
from pathlib import Path

import fitz


PAGE_W = 612
PAGE_H = 792
PANEL_X = (42, 184, 326, 468)
PANEL_Y = (90, 225, 360)
PANEL_W = 102
PANEL_H = 78


def draw_panel(page: fitz.Page, x0: float, y0: float, panel_id: int) -> None:
    """Draw one panel using PDF vector primitives only."""
    rect = fitz.Rect(x0, y0, x0 + PANEL_W, y0 + PANEL_H)
    page.draw_rect(rect, color=(0, 0, 0), width=0.8)

    # Use short line segments instead of one large polyline.  This keeps each
    # curve segment below the extractor's 10-point drawing-rect threshold,
    # leaving the 12 panel rectangles as the visual anchors.
    phase = panel_id * 0.37
    curve_a = []
    curve_b = []
    for i in range(25):
        x = x0 + 8 + i * 3.45
        t = i / 24
        y_a = y0 + 20 + 35 * (0.5 + 0.45 * math.sin(6.0 * t + phase))
        y_b = y0 + 20 + 35 * (0.5 + 0.40 * math.cos(4.5 * t + phase / 2))
        curve_a.append((x, y_a))
        curve_b.append((x, y_b))
    for points, color, width in (
        (curve_a, (0.1, 0.3, 0.8), 1.0),
        (curve_b, (0.8, 0.2, 0.2), 0.5),
    ):
        for start, end in zip(points, points[1:]):
            page.draw_line(start, end, color=color, width=width)


def build_pdf(output_path: Path) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    document = fitz.open()
    page = document.new_page(width=PAGE_W, height=PAGE_H)

    panel_id = 0
    for y0 in PANEL_Y:
        for x0 in PANEL_X:
            draw_panel(page, x0, y0, panel_id)
            panel_id += 1

    labels = (
        ("(a) panel one", ""),
        ("(b) panel two", ""),
        ("(c) panel three", ""),
        ("(d) panel four", ""),
        ("(e) panel five", ""),
        ("(f) panel six", ""),
        ("(g) panel seven", ""),
        ("(h) panel eight", ""),
        ("(i) bottom panel one", "continued label, d = 2000"),
        ("(j) bottom panel two", "continued label, d = 2000"),
        ("(k) bottom panel three", "continued label, d = 2000"),
        ("(l) bottom panel four", "continued label, d = 2000"),
    )
    index = 0
    for row, y0 in enumerate(PANEL_Y):
        for col, x0 in enumerate(PANEL_X):
            first, second = labels[index]
            baseline = y0 + PANEL_H + 22
            page.insert_text((x0 + 4, baseline), first, fontsize=10, fontname="helv", color=(0, 0, 0))
            if second:
                page.insert_text((x0 + 4, baseline + 18), second, fontsize=10, fontname="helv", color=(0, 0, 0))
            index += 1

    page.insert_text(
        (42, 565),
        "Figure 1: Synthetic vector figure with multi-line bottom subfigure labels.",
        fontsize=10,
        fontname="helv",
        color=(0, 0, 0),
    )
    document.save(output_path)
    document.close()


def build_fetch_json(pdf_path: Path, output_path: Path) -> None:
    output_path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "status": "ok",
        "script": "fetch_pdf.py",
        "paper_id": "synthetic-multiline-figure-bug",
        "pdf_path": str(pdf_path.resolve()),
        "identity_contract": {
            "status": "ok",
            "artifact_type": "canonical_identity",
            "schema_version": 2,
            "paper_id": "synthetic-multiline-figure-bug",
            "identity_verdict": "accepted",
        },
    }
    output_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("output", type=Path, help="destination PDF path")
    parser.add_argument("--fetch-json", type=Path, help="optional DeepPaperNote input JSON path")
    args = parser.parse_args()
    build_pdf(args.output)
    if args.fetch_json:
        build_fetch_json(args.output, args.fetch_json)
    print(args.output)


if __name__ == "__main__":
    main()

Disclosure

This issue report was drafted with assistance from ChatGPT. The synthetic reproduction case and the reported observations were generated and verified locally using the DeepPaperNote extraction script.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions