Skip to content

Page preview

The evidence-panel page-preview service (ADR-010): on-demand locate + render behind the two …/preview/* routes — markdown de-decoration, trigram page scoring and pdfium highlight rects, PNG page rendering, source resolution (containment + content-hash verification), and the cached LibreOffice PPTX conversion.

varagity.preview

Evidence-panel document page preview (ADR-010).

On-demand, server-side locate + render: given a chunk's text and its parent document, find the one page/slide containing it (word-trigram coverage over pdfium page text), compute highlight rectangles (pdfium text search over de-decorated snippets), and render that page to a PNG. No ingest-time provenance, no schema changes, no reingest — everything keys off the content-addressed doc_id, so results are immutable and cacheable forever.

ConversionFailed

Bases: Exception

A conversion ran and failed (non-zero exit, timeout, missing output).

Source code in varagity/preview/convert.py
37
38
class ConversionFailed(Exception):
    """A conversion ran and failed (non-zero exit, timeout, missing output)."""

ConversionUnavailable

Bases: Exception

No converter is available to this process (degrade, never crash).

Raised when LibreOffice is absent — a host-mode run without it loses only PPTX previews (the route answers conversion_unavailable).

Source code in varagity/preview/convert.py
29
30
31
32
33
34
class ConversionUnavailable(Exception):
    """No converter is available to this process (degrade, never crash).

    Raised when LibreOffice is absent — a host-mode run without it loses
    only PPTX previews (the route answers ``conversion_unavailable``).
    """

LocateResult dataclass

Where a chunk's text lives in its source document.

Attributes:

Name Type Description
page int | None

Best-matching page, 1-based; None when the best coverage stayed below the caller's floor (the no_match outcome).

page_count int

Total pages in the document.

rects list[tuple[float, float, float, float]]

Highlight rectangles (x0, y0, x1, y1), normalized to [0, 1] with a top-left origin (y-flipped from PDF coordinates server-side, so the client does no coordinate math beyond percentages).

coverage float

The winning page's containment score in [0, 1].

Source code in varagity/preview/locate.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True)
class LocateResult:
    """Where a chunk's text lives in its source document.

    Attributes:
        page: Best-matching page, 1-based; ``None`` when the best coverage
            stayed below the caller's floor (the ``no_match`` outcome).
        page_count: Total pages in the document.
        rects: Highlight rectangles ``(x0, y0, x1, y1)``, normalized to
            ``[0, 1]`` with a **top-left** origin (y-flipped from PDF
            coordinates server-side, so the client does no coordinate math
            beyond percentages).
        coverage: The winning page's containment score in ``[0, 1]``.
    """

    page: int | None
    page_count: int
    rects: list[tuple[float, float, float, float]]
    coverage: float

PreviewSource dataclass

A document resolved to an openable PDF, or the reason it degraded.

Attributes:

Name Type Description
pdf_path Path | None

The pdfium-openable PDF (the source itself for .pdf, the cached conversion for .pptx); None when degraded.

reason str | None

The degradable condition when pdf_path is None (preview_disabled | unsupported_type | file_missing | file_changed | conversion_unavailable | conversion_failed); None when pdf_path is set.

Source code in varagity/preview/source.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@dataclass(frozen=True)
class PreviewSource:
    """A document resolved to an openable PDF, or the reason it degraded.

    Attributes:
        pdf_path: The pdfium-openable PDF (the source itself for ``.pdf``,
            the cached conversion for ``.pptx``); ``None`` when degraded.
        reason: The degradable condition when ``pdf_path`` is ``None``
            (``preview_disabled`` | ``unsupported_type`` | ``file_missing``
            | ``file_changed`` | ``conversion_unavailable`` |
            ``conversion_failed``); ``None`` when ``pdf_path`` is set.
    """

    pdf_path: Path | None
    reason: str | None

conversion_cache_path(doc_id)

Return the cached-PDF path for one document's conversion.

Content-addressed: doc_id hashes the source path and its byte content, so a cache hit can never be stale. The cache lives in the process's temp directory — container-ephemeral by design (a restart re-pays one conversion per deck).

Parameters:

Name Type Description Default
doc_id str

The document's stable id.

required

Returns:

Type Description
Path

The cache path (existence not implied).

Source code in varagity/preview/convert.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def conversion_cache_path(doc_id: str) -> Path:
    """Return the cached-PDF path for one document's conversion.

    Content-addressed: ``doc_id`` hashes the source path and its byte
    content, so a cache hit can never be stale. The cache lives in the
    process's temp directory — container-ephemeral by design (a restart
    re-pays one conversion per deck).

    Args:
        doc_id: The document's stable id.

    Returns:
        The cache path (existence not implied).
    """
    return Path(tempfile.gettempdir()) / "varagity-preview" / f"{doc_id}.pdf"

ensure_pdf(source, doc_id, *, timeout_s)

Return a PDF rendition of source, converting on first use.

Cache first — a previously converted deck answers instantly (and keeps answering even if LibreOffice later disappears from the image). On a miss, one soffice --headless --convert-to pdf run executes under the module lock with a throwaway -env:UserInstallation profile, and its output moves atomically into the cache path (a concurrent reader never sees a partial file).

Parameters:

Name Type Description Default
source Path

The source document (a .pptx).

required
doc_id str

The document's stable id (the cache key).

required
timeout_s int

Conversion timeout in seconds (PREVIEW_CONVERT_TIMEOUT_S).

required

Returns:

Type Description
Path

The pdfium-openable converted PDF.

Raises:

Type Description
ConversionUnavailable

LibreOffice (soffice) is not on PATH.

ConversionFailed

The conversion ran and failed — non-zero exit, timeout, or no PDF produced.

Source code in varagity/preview/convert.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def ensure_pdf(source: Path, doc_id: str, *, timeout_s: int) -> Path:
    """Return a PDF rendition of ``source``, converting on first use.

    Cache first — a previously converted deck answers instantly (and keeps
    answering even if LibreOffice later disappears from the image). On a
    miss, one ``soffice --headless --convert-to pdf`` run executes under
    the module lock with a throwaway ``-env:UserInstallation`` profile,
    and its output moves atomically into the cache path (a concurrent
    reader never sees a partial file).

    Args:
        source: The source document (a ``.pptx``).
        doc_id: The document's stable id (the cache key).
        timeout_s: Conversion timeout in seconds (``PREVIEW_CONVERT_TIMEOUT_S``).

    Returns:
        The pdfium-openable converted PDF.

    Raises:
        ConversionUnavailable: LibreOffice (``soffice``) is not on ``PATH``.
        ConversionFailed: The conversion ran and failed — non-zero exit,
            timeout, or no PDF produced.
    """
    cached = conversion_cache_path(doc_id)
    if cached.is_file():
        return cached
    soffice = shutil.which("soffice")
    if soffice is None:
        raise ConversionUnavailable(
            "LibreOffice (soffice) is not on PATH — PPTX previews degrade to full text"
        )
    with _CONVERT_LOCK:
        if cached.is_file():  # a concurrent request converted it while we waited
            return cached
        cached.parent.mkdir(parents=True, exist_ok=True)
        # Scratch inside the cache directory: the final move is a same-
        # filesystem rename, so the cache path is atomically all-or-nothing
        # (partial-file pattern of the upload route).
        with tempfile.TemporaryDirectory(prefix=".convert-", dir=cached.parent) as scratch:
            produced = _run_soffice(soffice, source, Path(scratch), timeout_s=timeout_s)
            shutil.move(produced, cached)
    logger.info("converted %s to %s for preview", source.name, cached)
    return cached

normalize_chunk_text(text)

Strip markdown decoration, keeping the words as the page shows them.

Inter-word punctuation ("Firefly,", "12,400") survives — pdfium search needs the needle's characters to match the page text exactly, whitespace aside — while pure decoration (markers, pipes, placeholder comments) is removed and whitespace collapses to single spaces.

Parameters:

Name Type Description Default
text str

The chunk's markdown content.

required

Returns:

Type Description
str

The de-decorated text, whitespace-collapsed and stripped.

Source code in varagity/preview/normalize.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def normalize_chunk_text(text: str) -> str:
    """Strip markdown decoration, keeping the words as the page shows them.

    Inter-word punctuation (``"Firefly,"``, ``"12,400"``) survives — pdfium
    search needs the needle's characters to match the page text exactly,
    whitespace aside — while pure decoration (markers, pipes, placeholder
    comments) is removed and whitespace collapses to single spaces.

    Args:
        text: The chunk's markdown content.

    Returns:
        The de-decorated text, whitespace-collapsed and stripped.
    """
    text = _HTML_COMMENT.sub(" ", text)
    text = _MARKDOWN_ESCAPE.sub(r"\1", text)
    text = _TABLE_SEPARATOR_ROW.sub(" ", text)
    text = _HEADING_MARKER.sub("", text)
    text = _LIST_BULLET.sub("", text)
    text = text.replace("|", " ")
    text = _EMPHASIS.sub("", text)
    text = _DOT_LEADER.sub(" ", text)
    return _WHITESPACE.sub(" ", text).strip()

render_page_png(pdf_path, page, *, width)

Rasterize one page (1-based) to a PNG of the given pixel width.

Height follows the page's aspect ratio (scale = width / page_width in PDF canvas units).

Parameters:

Name Type Description Default
pdf_path Path

A pdfium-openable PDF.

required
page int

1-based page number.

required
width int

Target image width in pixels.

required

Returns:

Type Description
bytes

The PNG bytes.

Raises:

Type Description
IndexError

If page is outside 1..page_count.

PdfiumError

If the file cannot be opened as a PDF.

Source code in varagity/preview/render.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def render_page_png(pdf_path: Path, page: int, *, width: int) -> bytes:
    """Rasterize one page (1-based) to a PNG of the given pixel width.

    Height follows the page's aspect ratio (``scale = width / page_width``
    in PDF canvas units).

    Args:
        pdf_path: A pdfium-openable PDF.
        page: 1-based page number.
        width: Target image width in pixels.

    Returns:
        The PNG bytes.

    Raises:
        IndexError: If ``page`` is outside ``1..page_count``.
        pypdfium2.PdfiumError: If the file cannot be opened as a PDF.
    """
    with PDFIUM_LOCK:
        pdf = pdfium.PdfDocument(pdf_path)
        try:
            page_count = len(pdf)
            if not 1 <= page <= page_count:
                raise IndexError(f"page {page} out of range 1–{page_count}")
            page_obj = pdf[page - 1]
            try:
                page_width, _ = page_obj.get_size()
                bitmap = page_obj.render(scale=width / page_width)
                image = bitmap.to_pil()
                buffer = io.BytesIO()
                image.save(buffer, format="PNG")
                return buffer.getvalue()
            finally:
                page_obj.close()
        finally:
            pdf.close()

resolve_preview_source(info, settings)

Resolve one ingested document to a PDF the preview can open.

Every failure mode is a degradable outcome, never an exception — the routes turn reason into available:false (locate) or a 404 code (page image), and the GUI falls back to the full-text view.

Parameters:

Name Type Description Default
info DocumentInfo

The document's stored metadata (get_document row).

required
settings Settings

The effective settings (kill switch, DOCS_PATH, conversion timeout).

required

Returns:

Type Description
PreviewSource

The openable PDF path, or the reason there isn't one.

Source code in varagity/preview/source.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def resolve_preview_source(info: DocumentInfo, settings: Settings) -> PreviewSource:
    """Resolve one ingested document to a PDF the preview can open.

    Every failure mode is a *degradable* outcome, never an exception — the
    routes turn ``reason`` into ``available:false`` (locate) or a 404 code
    (page image), and the GUI falls back to the full-text view.

    Args:
        info: The document's stored metadata (``get_document`` row).
        settings: The effective settings (kill switch, ``DOCS_PATH``,
            conversion timeout).

    Returns:
        The openable PDF path, or the reason there isn't one.
    """
    if not settings.PREVIEW_ENABLED:
        return PreviewSource(pdf_path=None, reason="preview_disabled")
    source = Path(info.source)
    suffix = source.suffix.lower()
    if suffix not in _PREVIEWABLE_SUFFIXES:
        return PreviewSource(pdf_path=None, reason="unsupported_type")
    docs_root = Path(settings.DOCS_PATH).resolve()
    resolved = resolve_contained(source, docs_root)
    if resolved is None or not resolved.is_file():
        return PreviewSource(pdf_path=None, reason="file_missing")
    if content_hash(resolved.read_bytes()) != info.content_hash:
        return PreviewSource(pdf_path=None, reason="file_changed")
    if suffix == ".pptx":
        try:
            converted = ensure_pdf(
                resolved, info.doc_id, timeout_s=settings.PREVIEW_CONVERT_TIMEOUT_S
            )
        except ConversionUnavailable:
            return PreviewSource(pdf_path=None, reason="conversion_unavailable")
        except ConversionFailed:
            return PreviewSource(pdf_path=None, reason="conversion_failed")
        return PreviewSource(pdf_path=converted, reason=None)
    return PreviewSource(pdf_path=resolved, reason=None)

snippets(text, size=8, stride=4)

Break normalized text into short pdfium-searchable needles.

Sentences of at most size whitespace tokens search whole; longer ones become overlapping size-token windows every stride tokens (the overlap closes the highlight gaps non-overlapping windows leave), plus a tail window so the sentence's end is always covered. Tokens keep their punctuation — the needle must match the page's characters.

Parameters:

Name Type Description Default
text str

Normalized chunk text (:func:normalize_chunk_text output).

required
size int

Window length in whitespace tokens.

8
stride int

Window step in whitespace tokens.

4

Returns:

Type Description
list[str]

The needles, deduplicated, in reading order.

Source code in varagity/preview/normalize.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def snippets(text: str, size: int = 8, stride: int = 4) -> list[str]:
    """Break normalized text into short pdfium-searchable needles.

    Sentences of at most ``size`` whitespace tokens search whole; longer
    ones become overlapping ``size``-token windows every ``stride`` tokens
    (the overlap closes the highlight gaps non-overlapping windows leave),
    plus a tail window so the sentence's end is always covered. Tokens keep
    their punctuation — the needle must match the page's characters.

    Args:
        text: Normalized chunk text (:func:`normalize_chunk_text` output).
        size: Window length in whitespace tokens.
        stride: Window step in whitespace tokens.

    Returns:
        The needles, deduplicated, in reading order.
    """
    needles: list[str] = []
    for sentence in _SENTENCE_END.split(text):
        tokens = sentence.split()
        if not tokens:
            continue
        if len(tokens) <= size:
            needles.append(" ".join(tokens))
            continue
        for start in range(0, len(tokens) - size + 1, stride):
            needles.append(" ".join(tokens[start : start + size]))
        if (len(tokens) - size) % stride:
            needles.append(" ".join(tokens[-size:]))
    return list(dict.fromkeys(needles))

words(text)

Tokenize text into lowercase word tokens for page scoring.

Punctuation-insensitive by construction (\w+ runs), so chunk and page text tokenize identically regardless of markdown-vs-layout punctuation differences.

Parameters:

Name Type Description Default
text str

Any text (normalized chunk text or raw pdfium page text).

required

Returns:

Type Description
list[str]

The lowercase word tokens, in order.

Source code in varagity/preview/normalize.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def words(text: str) -> list[str]:
    r"""Tokenize text into lowercase word tokens for page scoring.

    Punctuation-insensitive by construction (``\w+`` runs), so chunk and
    page text tokenize identically regardless of markdown-vs-layout
    punctuation differences.

    Args:
        text: Any text (normalized chunk text or raw pdfium page text).

    Returns:
        The lowercase word tokens, in order.
    """
    return [match.group(0).lower() for match in _WORD.finditer(text)]

varagity.preview.normalize

Markdown de-decoration for preview text matching (ADR-010).

Chunk text is docling markdown — headings, emphasis, GFM table pipes, <!-- image -->-style placeholder comments, list bullets, dot leaders — while a PDF's text layer carries none of that decoration. These pure functions reduce a chunk to the words that actually appear on the page, so page scoring (:func:words) and pdfium snippet search (:func:snippets) can match against the extracted page text.

normalize_chunk_text(text)

Strip markdown decoration, keeping the words as the page shows them.

Inter-word punctuation ("Firefly,", "12,400") survives — pdfium search needs the needle's characters to match the page text exactly, whitespace aside — while pure decoration (markers, pipes, placeholder comments) is removed and whitespace collapses to single spaces.

Parameters:

Name Type Description Default
text str

The chunk's markdown content.

required

Returns:

Type Description
str

The de-decorated text, whitespace-collapsed and stripped.

Source code in varagity/preview/normalize.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
def normalize_chunk_text(text: str) -> str:
    """Strip markdown decoration, keeping the words as the page shows them.

    Inter-word punctuation (``"Firefly,"``, ``"12,400"``) survives — pdfium
    search needs the needle's characters to match the page text exactly,
    whitespace aside — while pure decoration (markers, pipes, placeholder
    comments) is removed and whitespace collapses to single spaces.

    Args:
        text: The chunk's markdown content.

    Returns:
        The de-decorated text, whitespace-collapsed and stripped.
    """
    text = _HTML_COMMENT.sub(" ", text)
    text = _MARKDOWN_ESCAPE.sub(r"\1", text)
    text = _TABLE_SEPARATOR_ROW.sub(" ", text)
    text = _HEADING_MARKER.sub("", text)
    text = _LIST_BULLET.sub("", text)
    text = text.replace("|", " ")
    text = _EMPHASIS.sub("", text)
    text = _DOT_LEADER.sub(" ", text)
    return _WHITESPACE.sub(" ", text).strip()

snippets(text, size=8, stride=4)

Break normalized text into short pdfium-searchable needles.

Sentences of at most size whitespace tokens search whole; longer ones become overlapping size-token windows every stride tokens (the overlap closes the highlight gaps non-overlapping windows leave), plus a tail window so the sentence's end is always covered. Tokens keep their punctuation — the needle must match the page's characters.

Parameters:

Name Type Description Default
text str

Normalized chunk text (:func:normalize_chunk_text output).

required
size int

Window length in whitespace tokens.

8
stride int

Window step in whitespace tokens.

4

Returns:

Type Description
list[str]

The needles, deduplicated, in reading order.

Source code in varagity/preview/normalize.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def snippets(text: str, size: int = 8, stride: int = 4) -> list[str]:
    """Break normalized text into short pdfium-searchable needles.

    Sentences of at most ``size`` whitespace tokens search whole; longer
    ones become overlapping ``size``-token windows every ``stride`` tokens
    (the overlap closes the highlight gaps non-overlapping windows leave),
    plus a tail window so the sentence's end is always covered. Tokens keep
    their punctuation — the needle must match the page's characters.

    Args:
        text: Normalized chunk text (:func:`normalize_chunk_text` output).
        size: Window length in whitespace tokens.
        stride: Window step in whitespace tokens.

    Returns:
        The needles, deduplicated, in reading order.
    """
    needles: list[str] = []
    for sentence in _SENTENCE_END.split(text):
        tokens = sentence.split()
        if not tokens:
            continue
        if len(tokens) <= size:
            needles.append(" ".join(tokens))
            continue
        for start in range(0, len(tokens) - size + 1, stride):
            needles.append(" ".join(tokens[start : start + size]))
        if (len(tokens) - size) % stride:
            needles.append(" ".join(tokens[-size:]))
    return list(dict.fromkeys(needles))

words(text)

Tokenize text into lowercase word tokens for page scoring.

Punctuation-insensitive by construction (\w+ runs), so chunk and page text tokenize identically regardless of markdown-vs-layout punctuation differences.

Parameters:

Name Type Description Default
text str

Any text (normalized chunk text or raw pdfium page text).

required

Returns:

Type Description
list[str]

The lowercase word tokens, in order.

Source code in varagity/preview/normalize.py
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def words(text: str) -> list[str]:
    r"""Tokenize text into lowercase word tokens for page scoring.

    Punctuation-insensitive by construction (``\w+`` runs), so chunk and
    page text tokenize identically regardless of markdown-vs-layout
    punctuation differences.

    Args:
        text: Any text (normalized chunk text or raw pdfium page text).

    Returns:
        The lowercase word tokens, in order.
    """
    return [match.group(0).lower() for match in _WORD.finditer(text)]

varagity.preview.locate

Find the page and highlight rects of a chunk's text (ADR-010).

Locate-at-preview-time: no per-chunk page/bbox provenance exists at ingest (ADR-005 §5 deferred it), so the preview path scores every page of the source PDF by word-trigram containment and computes highlight rectangles via pdfium text search — deterministic, format-agnostic, and working retroactively for every already-ingested corpus.

LocateResult dataclass

Where a chunk's text lives in its source document.

Attributes:

Name Type Description
page int | None

Best-matching page, 1-based; None when the best coverage stayed below the caller's floor (the no_match outcome).

page_count int

Total pages in the document.

rects list[tuple[float, float, float, float]]

Highlight rectangles (x0, y0, x1, y1), normalized to [0, 1] with a top-left origin (y-flipped from PDF coordinates server-side, so the client does no coordinate math beyond percentages).

coverage float

The winning page's containment score in [0, 1].

Source code in varagity/preview/locate.py
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@dataclass(frozen=True)
class LocateResult:
    """Where a chunk's text lives in its source document.

    Attributes:
        page: Best-matching page, 1-based; ``None`` when the best coverage
            stayed below the caller's floor (the ``no_match`` outcome).
        page_count: Total pages in the document.
        rects: Highlight rectangles ``(x0, y0, x1, y1)``, normalized to
            ``[0, 1]`` with a **top-left** origin (y-flipped from PDF
            coordinates server-side, so the client does no coordinate math
            beyond percentages).
        coverage: The winning page's containment score in ``[0, 1]``.
    """

    page: int | None
    page_count: int
    rects: list[tuple[float, float, float, float]]
    coverage: float

locate(pdf_path, chunk_text, *, min_coverage)

Find the best-matching page for a chunk and its highlight rects.

Pages are scored by word-trigram containment (unigram for very short chunks); ties keep the lowest page. On the winning page, the chunk's sentences become short search needles whose pdfium match rectangles — deduplicated and capped — form the highlight overlay.

Parameters:

Name Type Description Default
pdf_path Path

A pdfium-openable PDF (the source itself, or a converted rendition).

required
chunk_text str

The chunk's markdown content, as both wire shapes deliver it to the client.

required
min_coverage float

Coverage floor below which the result reports no page (PREVIEW_MIN_COVERAGE).

required

Returns:

Type Description
LocateResult

The located page, its highlight rects, and the coverage score.

Raises:

Type Description
PdfiumError

If the file cannot be opened as a PDF.

Source code in varagity/preview/locate.py
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def locate(pdf_path: Path, chunk_text: str, *, min_coverage: float) -> LocateResult:
    """Find the best-matching page for a chunk and its highlight rects.

    Pages are scored by word-trigram containment (unigram for very short
    chunks); ties keep the lowest page. On the winning page, the chunk's
    sentences become short search needles whose pdfium match rectangles —
    deduplicated and capped — form the highlight overlay.

    Args:
        pdf_path: A pdfium-openable PDF (the source itself, or a converted
            rendition).
        chunk_text: The chunk's markdown content, as both wire shapes
            deliver it to the client.
        min_coverage: Coverage floor below which the result reports no page
            (``PREVIEW_MIN_COVERAGE``).

    Returns:
        The located page, its highlight rects, and the coverage score.

    Raises:
        pypdfium2.PdfiumError: If the file cannot be opened as a PDF.
    """
    normalized = normalize_chunk_text(chunk_text)
    chunk_words = words(normalized)
    with PDFIUM_LOCK:
        pdf = pdfium.PdfDocument(pdf_path)
        try:
            page_texts = _page_texts(pdf)
            page_index, coverage = _best_page(chunk_words, page_texts)
            if page_index is None or coverage < min_coverage:
                return LocateResult(
                    page=None, page_count=len(page_texts), rects=[], coverage=coverage
                )
            rects = _highlight_rects(pdf, page_index, normalized)
            return LocateResult(
                page=page_index + 1, page_count=len(page_texts), rects=rects, coverage=coverage
            )
        finally:
            pdf.close()

varagity.preview.render

Render one document page to a PNG (ADR-010).

The image side of the preview pair: the locate route names a page, this renders it at PREVIEW_RENDER_WIDTH for the evidence panel's <img>. Every pdfium call — the PIL encode included, since the bitmap buffer belongs to pdfium — runs under :data:varagity.preview.locate.PDFIUM_LOCK.

render_page_png(pdf_path, page, *, width)

Rasterize one page (1-based) to a PNG of the given pixel width.

Height follows the page's aspect ratio (scale = width / page_width in PDF canvas units).

Parameters:

Name Type Description Default
pdf_path Path

A pdfium-openable PDF.

required
page int

1-based page number.

required
width int

Target image width in pixels.

required

Returns:

Type Description
bytes

The PNG bytes.

Raises:

Type Description
IndexError

If page is outside 1..page_count.

PdfiumError

If the file cannot be opened as a PDF.

Source code in varagity/preview/render.py
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
def render_page_png(pdf_path: Path, page: int, *, width: int) -> bytes:
    """Rasterize one page (1-based) to a PNG of the given pixel width.

    Height follows the page's aspect ratio (``scale = width / page_width``
    in PDF canvas units).

    Args:
        pdf_path: A pdfium-openable PDF.
        page: 1-based page number.
        width: Target image width in pixels.

    Returns:
        The PNG bytes.

    Raises:
        IndexError: If ``page`` is outside ``1..page_count``.
        pypdfium2.PdfiumError: If the file cannot be opened as a PDF.
    """
    with PDFIUM_LOCK:
        pdf = pdfium.PdfDocument(pdf_path)
        try:
            page_count = len(pdf)
            if not 1 <= page <= page_count:
                raise IndexError(f"page {page} out of range 1–{page_count}")
            page_obj = pdf[page - 1]
            try:
                page_width, _ = page_obj.get_size()
                bitmap = page_obj.render(scale=width / page_width)
                image = bitmap.to_pil()
                buffer = io.BytesIO()
                image.save(buffer, format="PNG")
                return buffer.getvalue()
            finally:
                page_obj.close()
        finally:
            pdf.close()

varagity.preview.source

Resolve an ingested document to a pdfium-openable PDF (ADR-010).

The gate both preview routes share: kill switch, format eligibility, DOCS_PATH containment (mirroring the delete route's rule — a stored source is data, not authority), on-disk content-hash verification (an edited-but-not-reingested file must degrade honestly, not preview the wrong bytes), and — for PPTX — the cached LibreOffice conversion.

PreviewSource dataclass

A document resolved to an openable PDF, or the reason it degraded.

Attributes:

Name Type Description
pdf_path Path | None

The pdfium-openable PDF (the source itself for .pdf, the cached conversion for .pptx); None when degraded.

reason str | None

The degradable condition when pdf_path is None (preview_disabled | unsupported_type | file_missing | file_changed | conversion_unavailable | conversion_failed); None when pdf_path is set.

Source code in varagity/preview/source.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@dataclass(frozen=True)
class PreviewSource:
    """A document resolved to an openable PDF, or the reason it degraded.

    Attributes:
        pdf_path: The pdfium-openable PDF (the source itself for ``.pdf``,
            the cached conversion for ``.pptx``); ``None`` when degraded.
        reason: The degradable condition when ``pdf_path`` is ``None``
            (``preview_disabled`` | ``unsupported_type`` | ``file_missing``
            | ``file_changed`` | ``conversion_unavailable`` |
            ``conversion_failed``); ``None`` when ``pdf_path`` is set.
    """

    pdf_path: Path | None
    reason: str | None

resolve_preview_source(info, settings)

Resolve one ingested document to a PDF the preview can open.

Every failure mode is a degradable outcome, never an exception — the routes turn reason into available:false (locate) or a 404 code (page image), and the GUI falls back to the full-text view.

Parameters:

Name Type Description Default
info DocumentInfo

The document's stored metadata (get_document row).

required
settings Settings

The effective settings (kill switch, DOCS_PATH, conversion timeout).

required

Returns:

Type Description
PreviewSource

The openable PDF path, or the reason there isn't one.

Source code in varagity/preview/source.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def resolve_preview_source(info: DocumentInfo, settings: Settings) -> PreviewSource:
    """Resolve one ingested document to a PDF the preview can open.

    Every failure mode is a *degradable* outcome, never an exception — the
    routes turn ``reason`` into ``available:false`` (locate) or a 404 code
    (page image), and the GUI falls back to the full-text view.

    Args:
        info: The document's stored metadata (``get_document`` row).
        settings: The effective settings (kill switch, ``DOCS_PATH``,
            conversion timeout).

    Returns:
        The openable PDF path, or the reason there isn't one.
    """
    if not settings.PREVIEW_ENABLED:
        return PreviewSource(pdf_path=None, reason="preview_disabled")
    source = Path(info.source)
    suffix = source.suffix.lower()
    if suffix not in _PREVIEWABLE_SUFFIXES:
        return PreviewSource(pdf_path=None, reason="unsupported_type")
    docs_root = Path(settings.DOCS_PATH).resolve()
    resolved = resolve_contained(source, docs_root)
    if resolved is None or not resolved.is_file():
        return PreviewSource(pdf_path=None, reason="file_missing")
    if content_hash(resolved.read_bytes()) != info.content_hash:
        return PreviewSource(pdf_path=None, reason="file_changed")
    if suffix == ".pptx":
        try:
            converted = ensure_pdf(
                resolved, info.doc_id, timeout_s=settings.PREVIEW_CONVERT_TIMEOUT_S
            )
        except ConversionUnavailable:
            return PreviewSource(pdf_path=None, reason="conversion_unavailable")
        except ConversionFailed:
            return PreviewSource(pdf_path=None, reason="conversion_failed")
        return PreviewSource(pdf_path=converted, reason=None)
    return PreviewSource(pdf_path=resolved, reason=None)

varagity.preview.convert

PPTX→PDF conversion for previews (ADR-010).

Headless LibreOffice Impress renders a deck to a PDF whose page N is slide N — the same identity docling relies on — so :mod:~varagity.preview.locate and :mod:~varagity.preview.render work unchanged on the converted artifact. Conversions are cached by doc_id (content-addressed, so a hit can never be stale), serialized behind a module lock (LibreOffice's profile is single-instance), and bounded by PREVIEW_CONVERT_TIMEOUT_S. A host-mode run without LibreOffice degrades (conversion_unavailable), never crashes.

ConversionFailed

Bases: Exception

A conversion ran and failed (non-zero exit, timeout, missing output).

Source code in varagity/preview/convert.py
37
38
class ConversionFailed(Exception):
    """A conversion ran and failed (non-zero exit, timeout, missing output)."""

ConversionUnavailable

Bases: Exception

No converter is available to this process (degrade, never crash).

Raised when LibreOffice is absent — a host-mode run without it loses only PPTX previews (the route answers conversion_unavailable).

Source code in varagity/preview/convert.py
29
30
31
32
33
34
class ConversionUnavailable(Exception):
    """No converter is available to this process (degrade, never crash).

    Raised when LibreOffice is absent — a host-mode run without it loses
    only PPTX previews (the route answers ``conversion_unavailable``).
    """

conversion_cache_path(doc_id)

Return the cached-PDF path for one document's conversion.

Content-addressed: doc_id hashes the source path and its byte content, so a cache hit can never be stale. The cache lives in the process's temp directory — container-ephemeral by design (a restart re-pays one conversion per deck).

Parameters:

Name Type Description Default
doc_id str

The document's stable id.

required

Returns:

Type Description
Path

The cache path (existence not implied).

Source code in varagity/preview/convert.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def conversion_cache_path(doc_id: str) -> Path:
    """Return the cached-PDF path for one document's conversion.

    Content-addressed: ``doc_id`` hashes the source path and its byte
    content, so a cache hit can never be stale. The cache lives in the
    process's temp directory — container-ephemeral by design (a restart
    re-pays one conversion per deck).

    Args:
        doc_id: The document's stable id.

    Returns:
        The cache path (existence not implied).
    """
    return Path(tempfile.gettempdir()) / "varagity-preview" / f"{doc_id}.pdf"

ensure_pdf(source, doc_id, *, timeout_s)

Return a PDF rendition of source, converting on first use.

Cache first — a previously converted deck answers instantly (and keeps answering even if LibreOffice later disappears from the image). On a miss, one soffice --headless --convert-to pdf run executes under the module lock with a throwaway -env:UserInstallation profile, and its output moves atomically into the cache path (a concurrent reader never sees a partial file).

Parameters:

Name Type Description Default
source Path

The source document (a .pptx).

required
doc_id str

The document's stable id (the cache key).

required
timeout_s int

Conversion timeout in seconds (PREVIEW_CONVERT_TIMEOUT_S).

required

Returns:

Type Description
Path

The pdfium-openable converted PDF.

Raises:

Type Description
ConversionUnavailable

LibreOffice (soffice) is not on PATH.

ConversionFailed

The conversion ran and failed — non-zero exit, timeout, or no PDF produced.

Source code in varagity/preview/convert.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
def ensure_pdf(source: Path, doc_id: str, *, timeout_s: int) -> Path:
    """Return a PDF rendition of ``source``, converting on first use.

    Cache first — a previously converted deck answers instantly (and keeps
    answering even if LibreOffice later disappears from the image). On a
    miss, one ``soffice --headless --convert-to pdf`` run executes under
    the module lock with a throwaway ``-env:UserInstallation`` profile,
    and its output moves atomically into the cache path (a concurrent
    reader never sees a partial file).

    Args:
        source: The source document (a ``.pptx``).
        doc_id: The document's stable id (the cache key).
        timeout_s: Conversion timeout in seconds (``PREVIEW_CONVERT_TIMEOUT_S``).

    Returns:
        The pdfium-openable converted PDF.

    Raises:
        ConversionUnavailable: LibreOffice (``soffice``) is not on ``PATH``.
        ConversionFailed: The conversion ran and failed — non-zero exit,
            timeout, or no PDF produced.
    """
    cached = conversion_cache_path(doc_id)
    if cached.is_file():
        return cached
    soffice = shutil.which("soffice")
    if soffice is None:
        raise ConversionUnavailable(
            "LibreOffice (soffice) is not on PATH — PPTX previews degrade to full text"
        )
    with _CONVERT_LOCK:
        if cached.is_file():  # a concurrent request converted it while we waited
            return cached
        cached.parent.mkdir(parents=True, exist_ok=True)
        # Scratch inside the cache directory: the final move is a same-
        # filesystem rename, so the cache path is atomically all-or-nothing
        # (partial-file pattern of the upload route).
        with tempfile.TemporaryDirectory(prefix=".convert-", dir=cached.parent) as scratch:
            produced = _run_soffice(soffice, source, Path(scratch), timeout_s=timeout_s)
            shutil.move(produced, cached)
    logger.info("converted %s to %s for preview", source.name, cached)
    return cached