Skip to content

๐Ÿ–จ Rendering flow

Status: reference.

How python -m chitragupta.draft render turns a draft into .md/.tex/.pdf/.docx, where a rendered draft's bibliography comes from, and what happens to a figure on the way through.

Written for someone changing chitragupta/render_output/, or trying to work out, for a specific rendered file, which of several places on disk its bibliography or its figure came from. Assumed you have read ARCHITECTURE.md's drafting layer. Not covered here: every render flag (CLI.md), the TikZ style rules a figure is held to (TIKZ-STYLE.md), and book assembly's own single bibtex pass over a composed book (WRITE-A-BOOK.md).

๐Ÿงญ Table of contents

๐Ÿ”€ The two paths through render()

render() (chitragupta/render_output/__init__.py) takes one of two routes, decided by output_format and the draft's own suffix:

  • --format md on a Markdown draft never reaches pandoc. This is a citation-numbering job, not a format conversion: references.write_numbered renumbers [@key] markers and appends an IEEE list in place, because pandoc's own Markdown writer would escape every marker to \[1\] and wrap the bibliography in {#refs} fenced divs that render as literal punctuation anywhere that isn't pandoc. With no pandoc to drop them, this path also drops the draft's raw LaTeX itself (_raw_tex.py, #1144): each ```{=latex} or {=tex} block and each `...`{=latex} span, the way pandoc's docx and html writers do, with no stray blank line or double space left behind. A raw block shown inside a longer code fence is an example and stays, and {=html} is kept, since a Markdown viewer renders it.
  • Every other combination goes through pandoc, against config.BIB_FILE_PATH, and (for pdf) LuaLaTeX. The rest of this document covers this path, including a .tex fragment rendered to .md: converting \citep{...} to Markdown is a real format conversion, so it does not qualify for the first bullet's shortcut.

Output always lands mirrored under content/rendered/: a draft at content/drafts/<topic>/<name>.tex renders to content/rendered/<topic>/<name>.{md,pdf,...}.

๐Ÿ“š Citation resolution: resolve now, or defer to the consuming document

Every genre-skill draft cites with Pandoc-style [@citekey] markers (or, for thesis-chapter-writer's .tex fragment, \citep{citekey}/ \citet{citekey}). _pandoc_command chooses between two rival strategies for resolving them, on one predicate:

Render Flags Who numbers the citations
Ordinary (the default) --citeproc --bibliography <bib> --csl <ieee.csl> pandoc, here and now, into the output
--fragment --natbib --bibliography <bib> the document that \inputs it, via one bibtex pass

Why a fragment must defer. Citeproc assigns numbers in the same pass that builds the list, so a fragment that resolved its own citations would restart at [1] in every chapter, and an assembled book collecting those into one bibliography would have half its markers pointing at the wrong entry while compiling cleanly. Deferring moves the resolution as well as the list; nothing else makes the numbers right. A thesis fragment wants the same behaviour for the same reason (thesis-chapter-writer documents its fragment as inheriting the thesis's own document-wide bibliography), so fragment is the whole discriminator and needs no further qualification.

The deferred path writes config.BIB_FILE_PATH beside the output as well (_assets._copy_book_bibliography), because bibtex resolves \bibliography{...} against the directory it runs in.

Two fixups run first, on temp copies only (_safe_render_inputs, chitragupta/render_output/_citeproc.py): aliasing a citekey containing -- (pandoc's tokenizer truncates it mid-key otherwise, silently dropping the citation; under --natbib it is worse, emitting \citep[art_2019]{tygesen_state}, a truncated key plus a spurious optional argument), and stripping control characters and joining decomposed accents (NFC) that content/parsed/ text can carry and LaTeX cannot. Neither the draft nor papers/bibliography.bib is ever written to; the .bib that lands beside a fragment is a copy with every ---bearing key aliased, not just the ones one draft cites, so the result does not depend on which unit was rendered last.

Every other character reaches pandoc as written.

๐Ÿ”ค The pdf engine and its fonts

A pdf render runs LuaLaTeX, the only engine for this project's own pdfs. It reads UTF-8 and OpenType fonts natively, so a character prints when a font has it. The fonts are in chitragupta/pdf_fonts.py: STIX Two Text and Math, Latin Modern Mono for code at its own size, and a luaotfload fallback chain (STIX Two Math, Noto Serif, DejaVu Sans, Noto Sans Symbols 2, Noto Serif Telugu, Devanagari and CJK SC, Unifont and Unifont Upper; the code font's chain puts DejaVu Sans Mono first). The chain is set in the shipped header assets/latex/chitragupta-lualatex.tex rather than through pandoc's mainfontfallback variable, because only newer pandoc templates know that variable; an older one, such as Ubuntu 24.04's apt pandoc 3.1.3, silently drops it. That chain prints every one of the 1,873 characters chitragupta-unicode.sty prints under pdflatex, so any draft pdflatex could print still prints (tests/test_render_output_fonts.py), as well as Telugu, Devanagari and Chinese.

A character no font in the chain has stops the build, by name. assets/latex/chitragupta-lualatex.tex sets \tracinglostchars=3 and a glyph_not_found callback whose error pandoc shows (! Missing character: There is no X (U+XXXX) ...); the CLI then adds a [unicode] hint. Without it, LuaTeX drops the glyph with only a log warning, which is a silent change of what the PDF says.

A review report and an evidence sidecar are the exception for one class of character. They quote the sources' text layers, which carry Unicode private-use code points (U+E000 to U+F8FF and planes 15 and 16) that no font has and no author can fix, so chitragupta/_private_use.py writes each one as a visible [U+E230] in the .md the render reads, and the command prints a note: line naming them. A report's .json keeps the original (#1125).

assets/latex/chitragupta-unicode.sty ships for the output someone else compiles: _unicode.py copies it beside a tex or --fragment output whose draft contains a character it maps, because the thesis or book that \inputs it may well be pdflatex. A pdf render does not load it; under LuaLaTeX it would be a no-op. A project's content/unicode-extra.tex goes into every LaTeX-bound render's preamble behind a two-line shim that defines \DeclareUnicodeCharacter on top of newunicodechar where the engine's kernel lacks it, so the same line works under pdflatex and LuaLaTeX.

A .bib field's raw TeX prints as text: bib_raw_tex_as_text.lua runs just before citeproc and turns raw TeX in every bibliography entry into literal text, and does the same for a $...$ math node that holds a code-running primitive, because LuaLaTeX would run a \directlua in either. Plain mathematics is left as math. It rewrites the entries rather than citeproc's output, because citeproc merges the author's own citation prefix and suffix ([\emph{cf.} @key, p. 3]) into the same node as the .bib text, and those are the author's to keep. TeX in the draft body, in a citation's prefix or suffix, and in figure files still runs, Lua included; that is an accepted residual, set out in SECURITY.md.

Build time. Measured on TeX Live 2023 with a 170-page, 63,000-word document made from this repository's sample drafts, warm cache: pdflatex 5.2 s, this LuaLaTeX setup 27.3 s (38.6 s on the first run, which builds the font cache). See plans/996-unicode-pdf-engine.md.

๐Ÿ”ข Mathematics: substituted on the pandoc path only

chitragupta/render_output/_math.py runs on the same temp copy, between the figure swap and _safe_render_inputs, and turns a draft's ASCII into mathematics pandoc can read (`tau` into $\tau$, and a <!-- math --> fence into $$โ€ฆ$$), using the ASCII-to-LaTeX table in the dossier's math.md (DOSSIER.md, WRITING-STANDARDS.md ยง12).

It turns on the same predicate as the two paths above. A render that reaches pandoc gets the substitution, and --format md, which does not reach pandoc, is a byte-perfect no-op; that is why the LaTeX lives outside the draft. A span with no row is left exactly as it was, so as_of stays \texttt{as\_of} by construction rather than by heuristic.

It writes $โ€ฆ$, never \(โ€ฆ\). Pandoc's Markdown reader does not read \(โ€ฆ\) as mathematics: handed A $k = 4$ and B \(k = 4\) it emits A \(k = 4\) and B (k = 4), silently dropping the second one's backslashes. $โ€ฆ$ is the native inline form, and each writer then renders it in its own idiom, which makes the output format-native rather than LaTeX-shaped.

Two conditions fail the render rather than warning, because both mean the pdf would carry verbatim text where the author said an equation goes: a <!-- math --> marker with no row, and one with no math.md at all. The second is what renaming a draft looks like, because a dossier is found by path alone. Heuristic gaps print [math] warnings and carry on, except for a file that can have no math.md (a review report), which prints none. So do the ## Symbols findings (#1143): a mapped symbol with no meaning, a meaning with no mapping row, a malformed row. The ## Symbols section is cut out before the mapping is read, and the dossier's glossary spans count as uses of a row, though substitution never touches the glossary: the book's pages apply each unit's mapping to it themselves.

๐Ÿ—‚ Five places a rendered bibliography can live

A render's citations don't resolve against a single file. Five different stores are involved, only one of them this pipeline's own, and confusing one for another is the easiest way to misread what a rendered draft proves:

What Where Who reads it
The bibliographic data papers/bibliography.bib: config.BIB_FILE_PATH (chitragupta/config.py), your own Zotero/JabRef export pandoc's --citeproc at render time; or, for a --fragment render, an aliased copy written beside the output for bibtex
The verification record content/ledger.sqlite python -m chitragupta.draft gate, references.py, retrieval; never the render itself
The rendered bibliography generated fresh into content/rendered/... on every render a preview artefact; nothing downstream reads it back
The real bibliography, for a .tex fragment specifically your own thesis's \bibliography{...}/biblatex resource, outside this repository entirely your own pdflatex+bibtex run, at submission
The real bibliography, for an assembled book content/rendered/<book>/, the copy the fragment render wrote there book.tex's \bibliography{...}, one bibtex pass over the whole book

The fourth row only applies to thesis-chapter-writer's output, and the fifth only to units assembled into a book. Every other genre's rendered draft is the bibliography-bearing artefact: its citeproc-built reference list is the one a reader sees. A .tex fragment's \citep{<citekey>} is deliberately left for the surrounding thesis to resolve: \inputed into your own document, your own \bibliography{}/biblatex run numbers it consistently with your other chapters. The .md/.pdf preview this pipeline renders from that same fragment (--format md/--format pdf, both run by thesis-chapter-writer step 12) still goes through rows one and three (citeproc against papers/bibliography.bib, into content/rendered/), because a preview has to resolve citations to be readable at all. That preview is not the deliverable and nothing downstream reads it back; the fragment on disk, unresolved \citep{} markers and all, is.

๐Ÿ“„ The manual References section, and why citeproc replaces it

Five of the six prose genres run python -m chitragupta.draft references <draft> after the gate, which writes a citekey-labelled ## References section built from exactly the citekeys the draft cites (references_section.py's section_start finds it by heading text). At render time, _swap_manual_refs_for_citeproc (chitragupta/render_output/_citeproc.py) replaces that section's entries with pandoc's own placement anchor ({#refs}\n:::\n) while keeping the heading. Citeproc's bibliography, which is the one numbered consistently with the inline markers and the one with authors and venues in it, therefore lands under the draft's own heading instead of a second, untitled list appearing at the end.

section_start recognises the bibliography under any of the three titles references_section.REFERENCE_TITLE names (References, Bibliography, Works cited), numbered or not, so a hand-titled ## Bibliography is swapped like a generated ## References, and draft references replaces it rather than appending a second list after it. The review layer's uncited-prose report and the typeset check compose the same title pattern, so all three agree on where the bibliography is.

thesis-chapter-writer skips the references step entirely (ARCHITECTURE.md's drafting layer). That is by design, and mechanically it could not run anyway: references.py's CLI takes "Path to the draft file (Markdown)", and section_start scans for a Markdown heading a .tex fragment never has. Because the fragment carries no ## References section, section_start returns None and _swap_manual_refs_for_citeproc returns the text unchanged. The function is citeproc-specific by construction, but it is harmless for a genre that never reaches the case it exists to fix, so that genre needs no exemption of its own.

๐Ÿ–ผ Figure substitution: four combinations, one real no-op

_with_figures_for (chitragupta/render_output/_figures.py) switches every figure marker to the form the target format can draw, one of four ways depending on the draft's own kind and whether the output is LaTeX-bound (tex/latex/pdf):

Draft carries Output wants What happens
Markdown figure: marker + ASCII twin LaTeX-bound _substitute_tikz_for_ascii: marker becomes a centred paragraph, {\centering\input{figures/<name>.tex}\par}; for pdf, with the figure's page mark before the \par (below)
Markdown figure: marker + ASCII twin non-LaTeX _substitute_ascii_for_marker: marker becomes a fenced ASCII block
.tex fragment's inline \input + ASCII twin non-LaTeX (.md preview) _substitute_ascii_for_tikz: \input{...} becomes a \begin{verbatim} block of the ASCII twin
.tex fragment's inline \input LaTeX-bound (tex/pdf) no-op by design: the TikZ is real and already inline, so nothing is substituted (but see open gaps)

The third row exists because pandoc's LaTeX reader "resolves the \input but then drops the tikzpicture environment, and keeps dropping it under -t markdown+raw_attribute" (_substitute_ascii_for_tikz's own docstring), so without the swap a .tex fragment's figure would silently vanish from its own .md preview.

What that table implies, and what sits beside it in the preamble:

  • The substitution itself adds no figure float or \caption. It is a bare \input. A float and a number come only from a caption line under the marker, which the caption pass wraps in \begin{figure}; an uncaptioned marker renders with no float and no number.
  • Only tex/latex/pdf ever draw the TikZ. md, html and docx all take row two and render the ASCII twin, which is why a panelled figure's (a)/(b) sub-captions have to exist in the .txt as well as in the picture: for three of the five formats the twin is the only thing the reader sees.
  • The TikZ libraries those figures ask for are loaded in the preamble, not in the figure file (#781). _pandoc_command reads the union of the \usetikzlibrary names across the draft's figure files (_tikz_libraries.py) and emits it beside \usepackage{tikz} as one header-includes value. It has to happen there rather than where the figure is \input: that point is inside a figure float, a float is a group, and a load inside one defines the library's macros locally while setting its loaded flag globally, so the second figure in the document skips the load and finds no macros. A --fragment render has no preamble for this to land in and prints the union instead, for the book that \inputs it (WRITE-A-BOOK.md).
  • Where a figure may float is set in the preamble too. Every standalone tex/latex/pdf render includes assets/latex/chitragupta-floats.tex, whose float fractions keep a figure that fits on a text page off a page of its own; a --fragment that draws a figure gets the file copied beside it and a [floats] line instead. Under LuaLaTeX the file also notes each figure that still lands on a float-only page, and _run_pandoc prints one [float-page] line per such page once pandoc returns. The note travels in the pdf's Info dictionary, because pandoc has deleted the log by then and the engine may not write a file outside pandoc's temp directory (plans/1065-float-placement.md).
  • A pdf render marks each figure's page. In a Markdown draft the \input is followed, inside the figure's own paragraph, by a PDF named destination, cgfig:<marker base> (render_output/_figure_anchor.py): no ink, no bookmark, and no word moved, which a test checks. draft render then reads the marks with pdfinfo -dests and writes each figure's figure look pictures and a PNG of its page beside the PDF (content/rendered/, or the render's --output-dir), as [figure-look] notes that never fail the render. A .tex draft gets no mark: pandoc's LaTeX reader would print one as text.

๐Ÿ”ข Figure numbering: a caption wraps the marker, in two passes

A captioned figure gets the same "author writes no number" contract ยง13 gives a table, via chitragupta/render_output/_figure_captions.py, a sibling of _figures.py rather than a part of it.

The Markdown contract is a figure: marker followed directly by its caption, no blank line between (_FIGURE_CAPTION_PAIR_RE):

1
2
<!-- figure: figures/delivery-modes -->
One reading path under three delivery modes.

Two passes run before _with_figures_for's own substitution, in _substituted (chitragupta/render_output/_substitution.py), because both read figures() off the original [marker, caption] text. After _with_figures_for replaces the marker with real content, that adjacency is gone:

Pass What it resolves LaTeX-bound (tex/latex/pdf) Everything else
substitute_captions The [marker, caption] pair Wraps the untouched marker in a \begin{figure}/\end{figure} pair, each its own raw ```{=latex} block; LaTeX's own counter numbers it, no \thefigure override ever written. The caption stays pandoc-visible text, with only `\caption{`{=latex} and `}\label{fig:<id>}`{=latex} injected around it as raw-attribute spans, so a caption's own [@key], & or % is Markdown-processed and LaTeX-escaped like any other prose rather than landing raw inside \caption{...} <marker>\n**Figure N:** <caption>, N counted in document order among captioned figures only
substitute_refs An inline <!-- figureref: <id> --> `Figure~\ref{fig:<id>}`{=latex}, for the same raw-attribute-span reason _tables._reference_for needs for ~ Figure N

Why \begin{figure} and \end{figure} cannot share one raw block with the caption between them. Pandoc's raw-TeX passthrough reads a bare \begin{env}...\end{env} span (the same mechanism \input{...} markers rely on elsewhere in this file) as one opaque block, copied to the writer byte-identical and never Markdown-processed. A caption inside such a span would be neither Markdown-processed nor LaTeX-escaped: a caption's & breaks the build, a % silently truncates the rest of the line (the \label included), and a caption's own [@key] reaches the PDF as literal, unresolved text. Splitting \begin{figure}/\end{figure} into two explicit fenced raw blocks, with the caption as ordinary pandoc content between them, is what lets the caption go through the Markdown reader at all. _tables._caption_for has the same shape, because a table's caption is never inside a hand-written raw environment either.

An uncaptioned marker matches neither pass's regex and renders exactly as "Figure substitution" above already describes: no float, no number, no \label. A figureref naming an uncaptioned figure's id, or one no figure declares at all, is left exactly as written; python -m chitragupta.draft style (chitragupta/style_figures.py) reports it; the render does not fail.

The .tex fragment (thesis-chapter-writer) carries neither marker and is untouched by either pass. It hand-authors a real \begin{figure} inline, the same carve-out the table section below states for a \begin{table}. Nothing writes a \renewcommand{\thefigure}, so the user's own thesis-wide figure counter numbers it.

The renderer never scales a figure, and is not to gain a way to. It \inputs a figure bare, at its natural size, and has no \resizebox, \scalebox or width option. A scaled figure prints its type at whatever the factor happens to be: six hand-written figures wrapped in \resizebox{\textwidth}{!}{...} printed their smallest type between 5.5pt and 7.1pt against a 9pt body, with pdflatex exiting 0 (TIKZ-STYLE.md). An overflowing figure is answered by its layout, which figure check's size-width reports, never by a scale knob, however reasonable the request looks when one figure is a few points too wide. This is written down as a decision because an absence gets filled. A scale in a document the pipeline does not write, such as a thesis that \inputs thesis-chapter-writer's fragment, is outside every check here; the drafting skills tell the author not to add one.

๐Ÿ”ข Table numbering: four cases, and pandoc numbers in only one

_tables.substitute (chitragupta/render_output/_tables.py) resolves WRITING-STANDARDS.md ยง13's two markers (the <!-- table: <id> --> under a caption line, and the inline <!-- tableref: <id> -->) into whatever the target format can count with. Every row was measured on pandoc 3.1.11.1, because the obvious assumption (pandoc numbers a captioned table) is true in exactly one of them:

Draft Output Caption becomes Reference becomes
.md tex, latex, pdf : <caption>\label{tab:<id>}; LaTeX counts it `Table~\ref{tab:<id>}`{=latex}
.md md **Table N:** <caption>, a paragraph, since this path never reaches pandoc Table N
.md docx, html, ... : Table N: <caption>, a real caption carrying a number pandoc will not supply Table N
.tex any untouched; the fragment writes \caption{}\label{} itself untouched

Three things in that table are not guessable:

  • The docx writer numbers nothing. A captioned table round-trips out of .docx as a bare caption paragraph, so the number has to be in the text pandoc is handed.
  • \label survives a Markdown caption; ~ does not. A raw \label{tab:x} written into a caption line reaches \caption{...\label{tab:x}} intact, but a bare Table~\ref{tab:x} in prose arrives as Table\textasciitilde{}\ref{tab:x}, because pandoc's Markdown reader owns ~ and escapes it. Hence the raw-attribute span in the first row.
  • Pandoc has no caption-attribute syntax, so an id cannot ride along in the caption: : Caption {#tbl:x} sets the literal text \{\#tbl:x\}. Nor is pandoc-crossref's @tbl:x available, since citation_gate.py reads a bare @key as a citekey and would fail the gate on tbl.

The substitution order in _substituted is figures, then tables, then mathematics, then equations, for two reasons. A figure substitution can insert a fenced ASCII block, and _math's displayed-equation rule reads fences; and equation numbering has to run after mathematics, for the reason the next section states.

On the --format md path for a Markdown draft, one more pass runs after all four: _raw_tex.dropped removes the raw LaTeX that path has no pandoc to drop. None of the four emits a raw span for md, but last is where it would catch one if a pass ever did.

๐Ÿ”ข Equation numbering: the one pass that reads what _math left behind

A marked equation gets the same "author writes no number" contract as a table or figure, via chitragupta/render_output/_equation_captions.py. Unlike either of those, not every displayed equation gets one: an author opts a specific equation in with <!-- equation: id --> directly above the existing <!-- math --> marker (docs/WRITING-STANDARDS.md ยง12), and an unmarked block (a derivation step) is untouched by every function in this module.

This module runs last, after _math.substitute. A table's or figure's caption sits beside content nothing else touches; an equation's marker sits above content _math.py itself rewrites into real $$...$$. That rewrite only happens on the pandoc path: render passes an empty mapping on the Markdown-to-Markdown path, so _math.substitute is a no-op there. This module therefore has to recognise two different shapes of the same marked block, keyed on format:

Draft Output A marked block becomes An equationref becomes
.md tex, latex, pdf, docx, html \begin{equation}\n<latex>\n\label{eq:<id>}\n\end{equation} (LaTeX-bound: LaTeX's own counter numbers it) or **Equation N:** above the kept $$...$$ (docx/html: pandoc numbers nothing outside LaTeX) `Equation~\ref{eq:<id>}`{=latex} (LaTeX-bound) or Equation N (docx/html)
.md md (never reaches pandoc) **Equation N:** above the untouched <!-- math --> marker and fence Equation N
.tex any untouched; thesis-chapter-writer writes \[...\]/\(...\) directly and has no marker vocabulary at all untouched

Because this pass is last, it needs no precomputed declared list handed in from _substituted the way figures do: nothing earlier in the chain moves an equation marker relative to another one, so counting matches in whatever text this module receives agrees with counting them in the pristine draft.

Numbering breaks the "the md path is a no-op" rule, on purpose and only for numbering. A marked equation's content is exactly as untouched on the md path as an unmarked one always was, but its number is written there, as **Equation N:**, the same way **Table N:** already is. python -m chitragupta.draft style (chitragupta/style_equations.py) reports a numbered equation nobody refers to, or an id problem, the same way style_tables.py/ style_figures.py do; it deliberately does not blank fenced code first the way those two do, because an equation's own marked block is itself a fence (see that module's docstring).

โœ‚ Three rewrites only a fragment gets

_substituted (chitragupta/render_output/_substitution.py) makes two changes to the temp copy of a --fragment render, never to the draft on disk:

  • The manual References section is dropped, heading included, with any text after it kept (_citeproc.drop_manual_refs). The document that \inputs the fragment resolves citations once for the whole of itself, so a per-chapter list would be a second, differently numbered answer.
  • A chapter number the top heading states is dropped (chitragupta/render_output/_chapter_number.py). A heading such as # Chapter 1: Why Anyone Pays would otherwise be numbered twice in an assembled book, because the book class supplies "Chapter 1" as well. It fires only on a top-level heading that already states a number, in both the Markdown and the \chapter{} spelling. The draft on disk keeps the number, because that heading titles the unit's own standalone pdf, where nothing else supplies one.

Both sit in _substituted rather than at the call site because that function is the one answer to "what does the writer actually see".

The third happens inside pandoc, because the labels it changes do not exist until pandoc's reader has run:

  • A derived heading label is prefixed with the unit id (#1122, assets/pandoc/unit_labels.lua, loaded by chitragupta/render_output/_unit_labels.py). pandoc labels a heading that has no {#id} from its text alone, so book units that each have a "Summary" section all define \label{summary}, and a \ref to it resolves to whichever chapter LaTeX read last. u1.md's Summary becomes u1-summary, and an in-unit link to it follows. A label the author wrote ({#id} or \label{id}) is left alone, because that is what the outline and the registry resolve against. pandoc's own --id-prefix would be the obvious tool, but its LaTeX writer ignores it. Measured on a seven-chapter book: 10 labels defined in more than one unit before, none after.

๐Ÿ•ณ Open gaps

  • A .tex fragment's TikZ is dropped from its own tex/pdf render. _pandoc_command (chitragupta/render_output/_pandoc.py) passes no -f/--from, so pandoc reads a .tex input with its LaTeX reader, which resolves \input{figures/<name>.tex} and then drops the tikzpicture environment inside it, with exit 0 and no warning. The fourth row of the figure substitution table assumes the TikZ passes through, and thesis-chapter-writer's step 9 says --format tex and --format pdf get it. Passing -f latex+raw_tex leaves \input{...} as a raw command for LuaLaTeX to read, which restores the figure; nothing passes it yet. Reproduce it directly:
1
2
3
4
$ printf '\\begin{tikzpicture}\\draw[blue] (0,0) circle (1);\\end{tikzpicture}\n' > figures/fig1.tex
$ printf '\\section{Framing}\nPrior work established Y.\n\n\\input{figures/fig1.tex}\n\nText after the figure.\n' > chapter.tex
$ pandoc chapter.tex --standalone -o out.tex; echo "exit=$?"
exit=0

out.tex keeps the section and both sentences; the tikzpicture is gone. - A .tex fragment's own tex/pdf preview resolves citations with --citeproc, not --natbib. --natbib is used for --fragment renders of Markdown units (see citation resolution), but a thesis fragment's preview still bakes in citeproc's numbering. thesis-chapter-writer depends on that today: the preview is the only place the fragment's citations are shown resolved. Switching it would need -f latex+raw_tex-auto_identifiers on the LaTeX reader (the fix above, plus no auto-generated \label to collide across chapters).