Skip to content

config

Loading and representation of opm.toml. See the Configuration guide for the TOML schema.

opm.config

Project-level configuration loaded from opm.toml.

All relative paths in the config (templates, CSS, documents, ODDs, pythonpath) are resolved relative to the directory containing the config file, so opm commands work regardless of the current working directory.

DEFAULT_WEBCOMPONENTS_URL module-attribute

DEFAULT_WEBCOMPONENTS_URL = "https://cdn.jsdelivr.net/npm/@teipublisher/pb-components@3.6.8/dist/pb-components-bundle.js"

Bundle loaded when web-component mode is on and the project names no URL.

Pinned here so the version is bumped in one place rather than in every project; override it per project with [transform.web.context] webcomponents_url.

FragmentConfig dataclass

FragmentConfig(
    name: str,
    scope: str,
    xpath: str,
    xpath_dynamic: str | None = None,
    parameters: dict[str, Any] | None = None,
    module: Path | None = None,
    odd: Path | None = None,
    mode: str = "web",
)

xpath_dynamic class-attribute instance-attribute

xpath_dynamic: str | None = None

The xpath the consuming pb-view sends, when it differs from xpath.

Used only to build --format pb-view index keys. See ChunkingConfig.xpath_dynamic.

module class-attribute instance-attribute

module: Path | None = None

Resolved compiled transform path (set after compile-on-demand, not from TOML).

odd class-attribute instance-attribute

odd: Path | None = None

ODD to compile on demand for this fragment.

mode class-attribute instance-attribute

mode: str = 'web'

Output channel used when compiling odd (default: web).

ChunkingConfig dataclass

ChunkingConfig(
    xpath: str | None = None,
    xpath_dynamic: str | None = None,
    selector: str | None = None,
    depth: int = 1,
    output_dir: str = "chunks",
    template: Path | None = None,
    index_template: Path | None = None,
    index_title: str | None = None,
    assets: tuple[Path, ...] = (),
    fragments: list[FragmentConfig] | None = None,
    link_pattern: str | None = None,
    link_doc: str | None = None,
    module: Path | None = None,
    odd: Path | None = None,
    view: str = "div",
    map: str | None = None,
    parameters: dict[str, Any] | None = None,
    doc_path: str | None = None,
)

The [chunking] section: how opm chunk splits a document into pages.

Relative paths are resolved against the config file's directory, except output_dir, which is relative to the project root.

xpath_dynamic class-attribute instance-attribute

xpath_dynamic: str | None = None

The xpath the consuming pb-view sends, when it differs from xpath.

--format pb-view writes an index.json whose keys mirror createKey() in pb-view.js, and pb-view looks itself up by the literal value of its own xpath attribute. That attribute names the region a view displays (//text[@type = 'source']), while xpath here selects chunk roots (.//text[@type='source']/div) — different expressions that are nonetheless compared as exact strings, so the lookup misses and pb-view requests a URL ending in undefined. Set this to the attribute's exact value to register the key pb-view will ask for. Only the index key changes; chunk selection still uses xpath.

index_template class-attribute instance-attribute

index_template: Path | None = None

Optional Jinja2 template for the collection index written when chunking a directory.

Rendered to <output_dir>/index.html so opm serve shows a real landing page instead of the bare directory listing. When None the packaged default_index.html.j2 is used.

index_title class-attribute instance-attribute

index_title: str | None = None

Heading for the generated collection index (default: the output directory name).

assets class-attribute instance-attribute

assets: tuple[Path, ...] = ()

Files or directories copied into <output-root>/assets/.

An entry may be a glob: iiif/* copies every directory under iiif/, so a project that adds a document does not have to add a line here.

Chunk output directories are wiped on every rebuild, so anything a template references — a stylesheet, an image, a font — has to be placed there by the build. Templates receive assets as a relative URL prefix (assets from the index, ../assets from a chunk page), and a stylesheet copied here can reference a sibling asset by plain filename, since its URLs resolve against its own location rather than the page's.

link_pattern: str | None = None

Optional URL template for cross-chunk links.

Placeholders

{file} – full filename, e.g. 002.html {stem} – stem without extension, e.g. 002 {anchor} – the fragment identifier, e.g. Pers {doc} – the document's own subdirectory, e.g. quickstart.xml (empty only when chunking with no link_doc)

When None (default) the rewriter falls back to the relative form {file}#{anchor}. Example values:

link_pattern = "/{doc}/{file}"              # per-document absolute paths
link_pattern = "/{doc}/{stem}/"             # clean URLs under the doc dir
link_pattern = "/{stem}#{anchor}"           # site-root absolute (single doc)
link_pattern = "http://localhost:8080/{stem}#{anchor}"
link_doc: str | None = None

Document path segment for {doc} in link_pattern (not from TOML).

Set by the CLI when chunking a directory of XML files into per-document output subdirectories (e.g. quickstart.xml).

module class-attribute instance-attribute

module: Path | None = None

Resolved compiled transform path (set after compile-on-demand, not from TOML).

odd class-attribute instance-attribute

odd: Path | None = None

ODD to compile on demand for chunking.

view class-attribute instance-attribute

view: str = 'div'

View mode (div, page or single) used in pb-view lookup keys.

map class-attribute instance-attribute

map: str | None = None

Optional map parameter included in pb-view lookup keys.

parameters class-attribute instance-attribute

parameters: dict[str, Any] | None = None

Optional user parameters for pb-view lookup keys.

Each entry is emitted as user.<key>=<value> and must match the pb-param children declared on the consuming pb-view.

doc_path class-attribute instance-attribute

doc_path: str | None = None

Document path subdirectory for --format pb-view output.

pb-view resolves static data as ${static}/${path}/...; the data is written to <output_dir>/<doc_path>/ (CSS stays shared at <output_dir>/css/). Must match the path of the consuming pb-document. When unset the data is written directly into output_dir.

CollectionConfig dataclass

CollectionConfig(uri: str, documents: tuple[Path, ...])

One fn:collection URI and the documents it contains.

uri is matched against the argument of collection() after elementpath resolves it (get_absolute_uri). A URI with a scheme, or an absolute path such as /db/apps/serafin/data/registers, is passed through verbatim, so the same string an eXist $config:register-root holds can be used here and will match from any source document. A relative URI would instead resolve against each document's own base URI, so it is rejected.

ProjectConfig dataclass

ProjectConfig(
    webcomponents_enabled: bool | None = None,
    template_context: dict[str, Any] = dict(),
    template_context_by_type: dict[
        str, dict[str, Any]
    ] = dict(),
    document_template: Path | None = None,
    document_css: Path | None = None,
    document_docx_template: Path | None = None,
    typst_template: Path | None = None,
    print_template: Path | None = None,
    epub_css: Path | None = None,
    epub_skip_title: bool = False,
    epub_chunk_overrides: dict[str, Any] = dict(),
    xpath_extensions: tuple[str, ...] = (),
    xpath_documents: tuple[Path, ...] = (),
    xpath_collections: tuple[CollectionConfig, ...] = (),
    xpath_variables: dict[str, Any] = dict(),
    xpath_namespaces: dict[str, str] = dict(),
    parameters: dict[str, str] = dict(),
    chunking: ChunkingConfig | None = None,
    index_max_chars: int = 1500,
    index_min_chars: int = 40,
    index_overlap: int = 1,
    index_fields: tuple = (),
    index_units: tuple = (),
    pythonpath: tuple[Path, ...] = (),
    transform_odd: Path | None = None,
    transform_odds: dict[str, Path] = dict(),
)

The settings in opm.toml, as load_project_config reads them.

Every field has a default, so ProjectConfig() is a project with no config file. Paths are already resolved against the config file's directory. To run anything with these settings, pass them to opm.project.Project.

webcomponents_enabled class-attribute instance-attribute

webcomponents_enabled: bool | None = None

Web-component mode, from [transform.web] webcomponents.

None means the project said nothing, leaving --webcomponents / --no-webcomponents to decide. The bundle URL is not configured here: it is an ordinary template value, webcomponents_url, defaulted by context_for and overridable in [transform.web.context].

template_context class-attribute instance-attribute

template_context: dict[str, Any] = field(
    default_factory=dict
)

Arbitrary values exposed to every Jinja2 template as context ([context]).

Unlike parameters, which is bound to XPath $parameters and so must be a flat map of strings, this keeps TOML types intact — booleans, numbers, arrays and nested tables all survive — because nothing but the template ever reads it. It is how a project drives its own template without a code change.

template_context_by_type class-attribute instance-attribute

template_context_by_type: dict[str, dict[str, Any]] = field(
    default_factory=dict
)

Per-output-type context overlays from [transform.<type>.context].

Merged over template_context by context_for, so a value the web template needs never leaks into the Typst one.

document_template class-attribute instance-attribute

document_template: Path | None = None

Jinja2 HTML shell for web output from [transform.web] template.

document_css class-attribute instance-attribute

document_css: Path | None = None

Base rules compiled into the ODD stylesheet, from [transform] css.

Replaces the packaged defaults for every output type.

print_template class-attribute instance-attribute

print_template: Path | None = None

Jinja2 HTML shell for -t print from [transform.print] template.

Print does not fall back to document_template — web shells usually include nav and web components that do not belong on a paged-media page. When unset, the packaged default_print.html.j2 is used.

epub_css class-attribute instance-attribute

epub_css: Path | None = None

Stylesheet appended to the EPUB package from [transform.epub] css.

Cascades last — after the packaged EPUB baseline and the ODD's own CSS — so it can restyle rules the reading view brought along.

epub_skip_title class-attribute instance-attribute

epub_skip_title: bool = False

When true, omit the generated EPUB title page ([transform.epub] skip_title).

epub_chunk_overrides class-attribute instance-attribute

epub_chunk_overrides: dict[str, Any] = field(
    default_factory=dict
)

xpath / selector / depth from [transform.epub].

What belongs in the book is not always what the reading view pages through. A parallel-text edition shows the translation in a second panel and chunks only the source; an EPUB has no second panel, so selecting the same chunks would drop half the document. Empty means "use [chunking] unchanged".

xpath_collections class-attribute instance-attribute

xpath_collections: tuple[CollectionConfig, ...] = ()

Collections addressable from XPath via fn:collection ([[transform.collections]]).

xpath_variables class-attribute instance-attribute

xpath_variables: dict[str, Any] = field(
    default_factory=dict
)

XPath variables in Clark notation ({ns}local).

From [transform.variables.<prefix>], where prefix is one declared in [transform.namespaces]; a namespace URI may be used as the key instead. Scalars directly under [transform.variables] are in no namespace.

xpath_namespaces class-attribute instance-attribute

xpath_namespaces: dict[str, str] = field(
    default_factory=dict
)

Prefix -> namespace URI for XPath in the ODD ([transform.namespaces]).

Merged over the ODD root's own declarations, so a project can bind prefixes the ODD never declares — variables in particular, whose prefix is meaningful only to XPath and has no XML meaning inside an attribute value.

parameters class-attribute instance-attribute

parameters: dict[str, str] = field(default_factory=dict)

User parameters bound to XPath $parameters (from [transform.parameters]).

index_max_chars class-attribute instance-attribute

index_max_chars: int = 1500

[index] max_chars — split a section longer than this for opm index.

index_min_chars class-attribute instance-attribute

index_min_chars: int = 40

[index] min_chars — drop units shorter than this; bare headings are noise.

index_overlap class-attribute instance-attribute

index_overlap: int = 1

[index] overlap — records of context carried into the next part on a split.

index_fields class-attribute instance-attribute

index_fields: tuple = ()

[[index.fields]]opm.indexing.FieldSpecs from a passage or a chunking fragment.

index_units class-attribute instance-attribute

index_units: tuple = ()

[[index.units]]opm.indexing.UnitSpecs that open a passage.

transform_odd class-attribute instance-attribute

transform_odd: Path | None = None

Default transform ODD from [transform].odd or [transform.web].odd.

transform_odds class-attribute instance-attribute

transform_odds: dict[str, Path] = field(
    default_factory=dict
)

Map of transform type → ODD path (compiled on demand).

Per-type [transform.<type>].odd entries override [transform].odd.

epub_chunking property

epub_chunking: ChunkingConfig | None

Chunking config the EPUB packager selects chapters with.

extend_sys_path

extend_sys_path() -> None

Put the [project] pythonpath directories on sys.path.

Project modules named in the config (XPath extensions, chunk selectors) import from there. Each entry goes to the front, as PYTHONPATH would put it; entries already present are left alone, so calling this again is harmless.

Source code in src/opm/config.py
def extend_sys_path(self) -> None:
    """Put the ``[project] pythonpath`` directories on ``sys.path``.

    Project modules named in the config (XPath extensions, chunk
    selectors) import from there. Each entry goes to the front, as
    ``PYTHONPATH`` would put it; entries already present are left alone,
    so calling this again is harmless.
    """
    for path in self.pythonpath:
        entry = str(path.resolve())
        if entry not in sys.path:
            sys.path.insert(0, entry)

context_for

context_for(
    transform_type: str | None = None,
    *,
    webcomponents: bool = False,
) -> dict[str, Any]

Return the template context for transform_type.

[context] supplies the base; [transform.<type>.context] overlays it. When webcomponents is on, webcomponents_url falls back to DEFAULT_WEBCOMPONENTS_URL — unless the project set that key itself, which then wins.

Whether the mode is on is a parameter rather than a config lookup because only the caller knows the effective mode for a run: --webcomponents overrides the config and the json/pb-view formats force it on.

Source code in src/opm/config.py
def context_for(
    self,
    transform_type: str | None = None,
    *,
    webcomponents: bool = False,
) -> dict[str, Any]:
    """Return the template ``context`` for *transform_type*.

    ``[context]`` supplies the base; ``[transform.<type>.context]`` overlays
    it. When *webcomponents* is on, ``webcomponents_url`` falls back to
    [`DEFAULT_WEBCOMPONENTS_URL`][opm.config.DEFAULT_WEBCOMPONENTS_URL] —
    unless the project set that key itself, which then wins.

    Whether the mode is on is a parameter rather than a config lookup
    because only the caller knows the effective mode for a run:
    ``--webcomponents`` overrides the config and the ``json``/``pb-view``
    formats force it on.
    """
    merged = dict(self.template_context)
    key = _section_for(transform_type)
    if key:
        merged.update(self.template_context_by_type.get(key, {}))
    if webcomponents:
        merged.setdefault('webcomponents_url', DEFAULT_WEBCOMPONENTS_URL)
    return merged

odd_for_type

odd_for_type(transform_type: str) -> Path | None

Return the ODD for transform_type.

Precedence: [transform.<type>].odd[transform].oddNone. The JSON channels (json-typst, …) read [transform.json].

Source code in src/opm/config.py
def odd_for_type(self, transform_type: str) -> Path | None:
    """Return the ODD for *transform_type*.

    Precedence: ``[transform.<type>].odd`` → ``[transform].odd`` → ``None``.
    The JSON channels (``json-typst``, …) read ``[transform.json]``.
    """
    return self.transform_odds.get(_section_for(transform_type)) or self.transform_odd

resolve_base_css

resolve_base_css(
    css_path: Path | None, project_root: Path
) -> str

Return the base stylesheet compiled into the ODD's generated CSS.

[transform] css / --css replaces the packaged default wholesale — it is an override for the rules the runtime's markup needs, not an extra layer. Project design CSS belongs in [chunking] assets instead, where it can sit beside the images and fonts it references.

Source code in src/opm/config.py
def resolve_base_css(css_path: Path | None, project_root: Path) -> str:
    """Return the base stylesheet compiled into the ODD's generated CSS.

    ``[transform] css`` / ``--css`` replaces the packaged default wholesale — it
    is an override for the rules the runtime's markup needs, not an extra layer.
    Project design CSS belongs in ``[chunking] assets`` instead, where it can
    sit beside the images and fonts it references.
    """
    from opm.odd_compiler.css_generator import default_base_css

    if css_path is not None:
        path = css_path if css_path.is_absolute() else project_root / css_path
        if not path.is_file():
            # Silently returning '' here would drop every base rule — the
            # popover and column-break styles included — for a typo.
            raise FileNotFoundError(f'Stylesheet not found: {path}')
        return path.read_text(encoding='utf-8')

    local = project_root / 'styles' / 'default-styles.css'
    if local.is_file():
        return local.read_text(encoding='utf-8')

    return default_base_css()

load_project_config

load_project_config(
    path: Path | None = None,
) -> ProjectConfig

Load opm.toml from path or CWD; return defaults if absent.

Source code in src/opm/config.py
def load_project_config(path: Path | None = None) -> ProjectConfig:
    """Load ``opm.toml`` from *path* or CWD; return defaults if absent."""
    config_path = path if path is not None else Path(CONFIG_FILENAME)
    if not config_path.is_file():
        return ProjectConfig()

    with config_path.open('rb') as f:
        data = tomllib.load(f)

    transform = _section_table(data.get('transform'))
    chunking_data = _section_table(data.get('chunking'))
    index_data = _section_table(data.get('index'))
    project_data = _section_table(data.get('project'))

    # Per-type tables: prefer [transform.<type>], accept legacy top-level [<type>].
    type_sections = {
        type_name: _resolve_type_section(transform, data, type_name)
        for type_name in CONFIG_SECTIONS
    }
    docx_data = type_sections['docx']
    typst_data = type_sections['typst']
    print_data = type_sections['print']
    epub_data = type_sections['epub']
    web_data = type_sections['web']
    webcomponents_enabled = web_data.get('webcomponents')
    if webcomponents_enabled is not None and not isinstance(webcomponents_enabled, bool):
        raise ValueError('opm.toml: transform.web.webcomponents must be a boolean')

    raw_context = data.get('context', {})
    if not isinstance(raw_context, dict):
        raise ValueError('opm.toml: [context] must be a table')
    # Values are handed to Jinja2 untouched: whatever TOML can express, a
    # template can read. No coercion, no key whitelist.
    template_context = dict(raw_context)
    template_context_by_type = {
        type_name: dict(_section_table(section.get('context')))
        for type_name, section in type_sections.items()
        if _section_table(section.get('context'))
    }

    # The HTML shell only wraps web output, so it lives with the other per-type
    # templates; the base CSS is compiled into every type's ODD stylesheet, so
    # it is shared.
    template = web_data.get('template')
    css_file = transform.get('css')
    if css_file is not None and not isinstance(css_file, str):
        raise ValueError('opm.toml: transform.css must be a path string')
    docx_template_file = docx_data.get('template')
    typst_template_file = typst_data.get('template')
    print_template_file = print_data.get('template')
    epub_css_file = epub_data.get('css')
    raw_xpath_extensions = transform.get('xpath_extensions')
    xpath_extensions: tuple[str, ...]
    if raw_xpath_extensions is None:
        xpath_extensions = ()
    elif isinstance(raw_xpath_extensions, str):
        xpath_extensions = (raw_xpath_extensions,)
    elif isinstance(raw_xpath_extensions, list):
        xpath_extensions = tuple(str(item) for item in raw_xpath_extensions)
    else:
        raise ValueError(
            'opm.toml: transform.xpath_extensions must be a string or list of strings',
        )

    raw_xpath_documents = transform.get('documents', [])
    if isinstance(raw_xpath_documents, str):
        raw_xpath_documents = [raw_xpath_documents]
    elif not isinstance(raw_xpath_documents, list):
        raise ValueError(
            'opm.toml: transform.documents must be a string or list of strings',
        )
    xpath_documents = tuple(config_path.parent / str(path) for path in raw_xpath_documents)

    raw_collections = transform.get('collections', [])
    if isinstance(raw_collections, dict):
        raw_collections = [raw_collections]
    elif not isinstance(raw_collections, list):
        raise ValueError(
            'opm.toml: transform.collections must be an array of tables',
        )
    collections: list[CollectionConfig] = []
    for entry in raw_collections:
        if not isinstance(entry, dict):
            raise ValueError('opm.toml: each transform.collections entry must be a table')
        uri = str(entry.get('uri', '')).strip()
        if not uri:
            raise ValueError('opm.toml: transform.collections entry is missing "uri"')
        parts = urlsplit(uri)
        if not parts.scheme and not parts.netloc and not parts.path.startswith('/'):
            raise ValueError(
                f'opm.toml: transform.collections uri {uri!r} must be absolute — a relative '
                'URI resolves against each source document, so it would not match reliably',
            )
        raw_members = entry.get('documents', [])
        if isinstance(raw_members, str):
            raw_members = [raw_members]
        elif not isinstance(raw_members, list):
            raise ValueError(
                f'opm.toml: transform.collections["{uri}"].documents must be a string or list',
            )
        collections.append(
            CollectionConfig(
                uri=uri,
                documents=tuple(config_path.parent / str(p) for p in raw_members),
            ),
        )

    raw_namespaces = transform.get('namespaces', {})
    if not isinstance(raw_namespaces, dict):
        raise ValueError('opm.toml: transform.namespaces must be a table of prefix = uri')
    xpath_namespaces: dict[str, str] = {}
    for prefix, uri in raw_namespaces.items():
        if not isinstance(uri, str):
            raise ValueError(
                f'opm.toml: transform.namespaces["{prefix}"] must be a namespace URI string',
            )
        xpath_namespaces[str(prefix)] = uri

    raw_variables = transform.get('variables', {})
    if not isinstance(raw_variables, dict):
        raise ValueError('opm.toml: transform.variables must be a table')
    xpath_variables: dict[str, Any] = {}

    def _scalar(value: Any, where: str) -> Any:
        # Keep TOML scalars typed. Stringifying a boolean is actively wrong:
        # ``str(False)`` is 'False', a non-empty string, whose effective boolean
        # value in XPath is *true* — so ``if ($global:address-by-id)`` would take
        # the wrong branch for ``address-by-id = false``.
        if isinstance(value, (bool, int, float, str)):
            return value
        raise ValueError(f'opm.toml: {where} must be a string, number or boolean')

    for key, entries in raw_variables.items():
        if not isinstance(entries, dict):
            # A scalar straight under [transform.variables] is a variable in no
            # namespace, referenced as ``$name``.
            xpath_variables[str(key)] = _scalar(entries, f'transform.variables.{key}')
            continue
        # A sub-table groups variables by the namespace *prefix* declared in
        # [transform.namespaces], so the URI is written once. A key that is
        # itself a URI is still accepted, for a namespace used only here.
        if ':' in key or '/' in key:
            namespace = str(key)
        else:
            namespace = xpath_namespaces.get(str(key), '')
            if not namespace:
                known = ', '.join(sorted(xpath_namespaces)) or '(none declared)'
                raise ValueError(
                    f'opm.toml: transform.variables.{key} — prefix "{key}" is not declared in '
                    f'[transform.namespaces]. Declared prefixes: {known}',
                )
        for local_name, value in entries.items():
            where = f'transform.variables.{key}.{local_name}'
            # Clark notation: elementpath accepts it directly as a variable key.
            clark = f'{{{namespace}}}{local_name}' if namespace else str(local_name)
            xpath_variables[clark] = _scalar(value, where)

    raw_parameters = transform.get('parameters', {})
    if not isinstance(raw_parameters, dict):
        raise ValueError('opm.toml: transform.parameters must be a table')
    # Nested [transform.<type>] tables are also dict values; only keep scalar params.
    parameters = {
        str(key): str(value)
        for key, value in raw_parameters.items()
        if not isinstance(value, dict)
    }

    # Parse chunking configuration
    chunking: ChunkingConfig | None = None
    if chunking_data:
        fragments: list[FragmentConfig] = []
        for frag_data in chunking_data.get('fragments', []):
            if not isinstance(frag_data, dict):
                continue
            raw_frag_odd = frag_data.get('odd')
            fragment = FragmentConfig(
                name=frag_data.get('name', ''),
                scope=frag_data.get('scope', 'per-chunk'),
                xpath=frag_data.get('xpath', '.'),
                xpath_dynamic=frag_data.get('xpath_dynamic'),
                parameters=frag_data.get('parameters'),
                odd=config_path.parent / str(raw_frag_odd) if raw_frag_odd else None,
                mode=str(frag_data.get('mode', 'web')).strip().lower() or 'web',
            )
            if fragment.name and fragment.scope in ('global', 'per-chunk'):
                fragments.append(fragment)

        chunking_template = chunking_data.get('template')
        chunking_index_template = chunking_data.get('index_template')
        raw_chunking_odd = chunking_data.get('odd')
        chunking = ChunkingConfig(
            xpath=chunking_data.get('xpath'),
            xpath_dynamic=chunking_data.get('xpath_dynamic'),
            selector=chunking_data.get('selector'),
            depth=chunking_data.get('depth', 1),
            output_dir=chunking_data.get('output_dir', 'chunks'),
            template=config_path.parent / str(chunking_template) if chunking_template else None,
            index_template=(
                config_path.parent / str(chunking_index_template)
                if chunking_index_template
                else None
            ),
            index_title=chunking_data.get('index_title'),
            assets=tuple(
                config_path.parent / str(asset)
                for asset in (chunking_data.get('assets') or ())
            ),
            fragments=fragments if fragments else None,
            link_pattern=chunking_data.get('link_pattern'),
            odd=config_path.parent / str(raw_chunking_odd) if raw_chunking_odd else None,
            view=chunking_data.get('view', 'div'),
            map=chunking_data.get('map'),
            parameters=chunking_data.get('parameters'),
            doc_path=chunking_data.get('doc_path'),
        )

    raw_pythonpath = project_data.get('pythonpath', [])
    if isinstance(raw_pythonpath, str):
        raw_pythonpath = [raw_pythonpath]
    pythonpath = tuple(config_path.parent / p for p in raw_pythonpath)

    raw_default_odd = transform.get('odd')
    transform_odd = (
        config_path.parent / str(raw_default_odd) if raw_default_odd else None
    )

    transform_odds: dict[str, Path] = {}
    for type_name, section in type_sections.items():
        raw_type_odd = section.get('odd')
        if raw_type_odd:
            transform_odds[type_name] = config_path.parent / str(raw_type_odd)

    # Legacy convenience: [transform.web].odd alone still acts as the default
    # when [transform].odd is omitted (keeps transform_odd / chunking fallbacks).
    if transform_odd is None:
        transform_odd = transform_odds.get('web')

    # Chunking inherits the shared transform ODD when [chunking].odd is omitted.
    if chunking is not None and chunking.odd is None and transform_odd is not None:
        chunking = replace(chunking, odd=transform_odd)

    index_fields = _index_fields(index_data)
    _check_index_fragment_fields(index_fields, chunking)

    return ProjectConfig(
        webcomponents_enabled=webcomponents_enabled,
        template_context=template_context,
        template_context_by_type=template_context_by_type,
        document_template=config_path.parent / str(template) if template else None,
        document_css=config_path.parent / str(css_file) if css_file else None,
        document_docx_template=config_path.parent / docx_template_file if docx_template_file else None,
        typst_template=config_path.parent / typst_template_file if typst_template_file else None,
        print_template=(
            config_path.parent / str(print_template_file) if print_template_file else None
        ),
        epub_css=config_path.parent / str(epub_css_file) if epub_css_file else None,
        epub_skip_title=bool(epub_data.get('skip_title', False)),
        epub_chunk_overrides={
            key: epub_data[key]
            for key in ('xpath', 'selector', 'depth')
            if key in epub_data
        },
        xpath_extensions=xpath_extensions,
        xpath_documents=xpath_documents,
        xpath_collections=tuple(collections),
        xpath_variables=xpath_variables,
        xpath_namespaces=xpath_namespaces,
        parameters=parameters,
        chunking=chunking,
        index_max_chars=int(index_data.get('max_chars', 1500)),
        index_min_chars=int(index_data.get('min_chars', 40)),
        index_overlap=int(index_data.get('overlap', 1)),
        index_fields=index_fields,
        index_units=_index_units(index_data),
        pythonpath=pythonpath,
        transform_odd=transform_odd,
        transform_odds=transform_odds,
    )