diff --git a/doclang/_packaging.py b/doclang/_packaging.py index 0dbf10e..b917366 100644 --- a/doclang/_packaging.py +++ b/doclang/_packaging.py @@ -17,6 +17,17 @@ + + + + + + + + + + + """ @@ -37,6 +48,9 @@ Mapping[int, Union[str, Path]], ] +# Whole-track media (audio/, video/) uses the same 1-based positional model as pages/. +MediaInput = PagesInput + AssetsInput = Union[ str, Path, @@ -96,25 +110,71 @@ def _place_document(stage: Path, document: Path) -> None: shutil.copy2(document, stage / "document.xml") -def _place_pages(stage: Path, pages: PagesInput) -> None: - pages_dir = stage / "pages" - if isinstance(pages, Mapping): - for page_number, source in pages.items(): - if not isinstance(page_number, int) or page_number < 1: - raise PackagingError(f"Page numbers must be positive integers, got {page_number!r}") +def _place_indexed( + stage: Path, + spec: PagesInput, + *, + subdir: str, + file_label: str, + dir_label: str, + number_label: str, +) -> None: + """Place 1-based positionally-indexed files (``{N}.{ext}``) under ``stage/subdir``. + + Shared by ``pages/`` (page images) and ``audio/`` / ``video/`` (whole-track media), + which all key a file to an integer position in the document. + """ + target_dir = stage / subdir + if isinstance(spec, Mapping): + for number, source in spec.items(): + if not isinstance(number, int) or number < 1: + raise PackagingError(f"{number_label} must be positive integers, got {number!r}") source_path = Path(source) - destination = pages_dir / f"{page_number}{source_path.suffix}" - _copy_file(source_path, destination, label="Page file") + destination = target_dir / f"{number}{source_path.suffix}" + _copy_file(source_path, destination, label=file_label) return - if isinstance(pages, str | Path): - _copy_tree_into(Path(pages), pages_dir, label="Pages directory") + if isinstance(spec, str | Path): + _copy_tree_into(Path(spec), target_dir, label=dir_label) return - for index, source in enumerate(pages, start=1): + for index, source in enumerate(spec, start=1): source_path = Path(source) - destination = pages_dir / f"{index}{source_path.suffix}" - _copy_file(source_path, destination, label="Page file") + destination = target_dir / f"{index}{source_path.suffix}" + _copy_file(source_path, destination, label=file_label) + + +def _place_pages(stage: Path, pages: PagesInput) -> None: + _place_indexed( + stage, + pages, + subdir="pages", + file_label="Page file", + dir_label="Pages directory", + number_label="Page numbers", + ) + + +def _place_audio(stage: Path, audio: MediaInput) -> None: + _place_indexed( + stage, + audio, + subdir="audio", + file_label="Audio file", + dir_label="Audio directory", + number_label="Audio track numbers", + ) + + +def _place_video(stage: Path, video: MediaInput) -> None: + _place_indexed( + stage, + video, + subdir="video", + file_label="Video file", + dir_label="Video directory", + number_label="Video track numbers", + ) def _place_assets(stage: Path, assets: AssetsInput) -> None: @@ -164,6 +224,8 @@ def _pack( output: Union[str, Path, None] = None, pages: PagesInput | None = None, assets: AssetsInput | None = None, + audio: MediaInput | None = None, + video: MediaInput | None = None, validate: bool = False, ) -> Path: document_path = Path(document) @@ -176,6 +238,10 @@ def _pack( _place_pages(stage, pages) if assets is not None: _place_assets(stage, assets) + if audio is not None: + _place_audio(stage, audio) + if video is not None: + _place_video(stage, video) _write_opc_metadata(stage) if validate: diff --git a/doclang/cli.py b/doclang/cli.py index 0db1f5f..32fced7 100644 --- a/doclang/cli.py +++ b/doclang/cli.py @@ -200,6 +200,38 @@ def pack( file_okay=False, dir_okay=True, ), + audio_dir: Path | None = typer.Option( + None, + "--audio-dir", + help="Directory of whole-track audio files (2.mp3, 4.ogg, …)", + exists=True, + file_okay=False, + dir_okay=True, + ), + audio_files: list[Path] | None = typer.Option( + None, + "--audio", + help="Whole-track audio file; repeat to add tracks in order (renumbered as 1.ext, 2.ext, …)", + exists=True, + file_okay=True, + dir_okay=False, + ), + video_dir: Path | None = typer.Option( + None, + "--video-dir", + help="Directory of whole-track video files (3.mp4, 4.mkv, …)", + exists=True, + file_okay=False, + dir_okay=True, + ), + video_files: list[Path] | None = typer.Option( + None, + "--video", + help="Whole-track video file; repeat to add tracks in order (renumbered as 1.ext, 2.ext, …)", + exists=True, + file_okay=True, + dir_okay=False, + ), validate_before_pack: bool = typer.Option(False, "--validate", help="Validate document before packing"), quiet: bool = typer.Option(False, "--quiet", "-q", help="Quiet mode (exit code only)"), ): @@ -207,8 +239,10 @@ def pack( Pack a DocLang markup file and optional media into a .dclx archive. DOCUMENT is copied to document.xml. Optional page images (--pages, --page) - are placed under pages/. Optional payload files (--assets, --asset) are - placed under assets/ for URIs referenced in the markup. OPC metadata + are placed under pages/. Optional whole-track audio/video (--audio-dir, + --audio, --video-dir, --video) are placed under audio/ and video/ as + {N}.{ext} for the Nth . Optional payload files (--assets, --asset) + are placed under assets/ for URIs referenced in the markup. OPC metadata ([Content_Types].xml, _rels/.rels) is generated automatically. By default, writes .dclx next to the input file. @@ -219,6 +253,7 @@ def pack( doclang pack markup.dclg -o report.dclx --pages screenshots/ doclang pack markup.dclg --page a.png --page b.png doclang pack markup.dclg --asset chart.svg=exports/diagram.svg + doclang pack markup.dclg --audio-dir recordings/ --video-dir recordings/ doclang pack markup.dclg --assets payload/ --validate """ if pages_dir is not None and page_files: @@ -227,6 +262,12 @@ def pack( if assets_dir is not None and asset_mappings: typer.echo("Error: --assets and --asset are mutually exclusive", err=True) raise typer.Exit(1) + if audio_dir is not None and audio_files: + typer.echo("Error: --audio-dir and --audio are mutually exclusive", err=True) + raise typer.Exit(1) + if video_dir is not None and video_files: + typer.echo("Error: --video-dir and --video are mutually exclusive", err=True) + raise typer.Exit(1) output_path = output or document.with_suffix(".dclx") @@ -246,12 +287,17 @@ def pack( else: assets = None + audio: Path | list[Path] | None = audio_dir if audio_dir is not None else (audio_files or None) + video: Path | list[Path] | None = video_dir if video_dir is not None else (video_files or None) + try: created = pack_document( document, output=output_path, pages=pages, assets=assets, + audio=audio, + video=video, validate=validate_before_pack, ) except ValidationError as exc: diff --git a/doclang/doclang.sch b/doclang/doclang.sch index 222b704..39a8f0a 100644 --- a/doclang/doclang.sch +++ b/doclang/doclang.sch @@ -27,6 +27,27 @@ + + + + + + + + + + + Track must have bdiv (optionally preceded by a single cover) as first element after the optional element head (property elements: label, thread, xref, href, layer, location, caption, description, summary, custom). + Found: + + + + Track must not contain non-whitespace text before its first cue block (bdiv). + Found: '' + + + + @@ -80,7 +101,7 @@ + dl:list | dl:table | dl:index | dl:group | dl:track | dl:voice | dl:chapter | dl:cover | dl:frame | dl:audio"> @@ -355,4 +376,158 @@ + + + + + + + + + + + + + + + A track cue block must begin with a start timestamp: its first element must be hours, minutes or seconds. + Found: + + + + A track cue block must not contain non-whitespace text before its start timestamp. + Found: '' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + A track cue block end timestamp must not be earlier than its start timestamp. + Found start=ms, end=ms. + + + + + + + + + + + + + + + + + + + + + + + + + + + Track cue blocks must appear in non-decreasing order of start time. + Found this cue block start=ms, previous cue block start=ms. + + + + + + + + + + + + + + + + + + + + + + + + + + Each chapter must begin strictly later than the previous chapter; two chapters cannot mark the same instant. + Found this chapter start=ms, previous chapter start=ms. + + + + + + + + + + + + + + + A track cue block with an audio clip must carry an end time; the clip spans the cue block's interval [start, end]. + + + + diff --git a/doclang/doclang.xsd b/doclang/doclang.xsd index 6839cce..a04e3c7 100644 --- a/doclang/doclang.xsd +++ b/doclang/doclang.xsd @@ -455,7 +455,7 @@ - + @@ -478,6 +478,7 @@ + @@ -693,6 +694,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/doclang/packaging.py b/doclang/packaging.py index c6f73b6..fa1f468 100644 --- a/doclang/packaging.py +++ b/doclang/packaging.py @@ -17,6 +17,8 @@ def pack( output: Union[str, Path, None] = None, pages: (Union[str, Path] | Sequence[Union[str, Path]] | Mapping[int, Union[str, Path]] | None) = None, assets: (Union[str, Path] | Mapping[str, Union[str, Path]] | None) = None, + audio: (Union[str, Path] | Sequence[Union[str, Path]] | Mapping[int, Union[str, Path]] | None) = None, + video: (Union[str, Path] | Sequence[Union[str, Path]] | Mapping[int, Union[str, Path]] | None) = None, validate: bool = False, ) -> Path: """Pack a DocLang markup file and optional media into a ``.dclx`` OPC archive. @@ -31,11 +33,18 @@ def pack( paths (renumbered as ``1.ext``, ``2.ext``, …), or a mapping of page number to image path. + ``audio`` and ``video`` carry whole-track media, one file per ````, + placed under ``audio/`` and ``video/`` as ``{N}.{ext}`` where ``N`` is the + 1-based position of the ```` in document order. Each accepts the same + forms as ``pages`` (directory, sequence, or ``{track_number: path}`` mapping); + the mapping form suits the common sparse case where only some tracks have media. + ``assets`` may be a directory (copied into ``assets/``) or a mapping of archive-relative asset path to source file. - Symbolic links are refused for ``pages`` and ``assets`` (including entries - inside directories) so pack does not follow link targets into the archive. + Symbolic links are refused for ``pages``, ``audio``, ``video`` and ``assets`` + (including entries inside directories) so pack does not follow link targets + into the archive. Returns the resolved path to the created archive. @@ -48,5 +57,7 @@ def pack( output=output, pages=pages, assets=assets, + audio=audio, + video=video, validate=validate, ) diff --git a/doclang/tokenization.py b/doclang/tokenization.py index 81d603a..9e1e4cc 100644 --- a/doclang/tokenization.py +++ b/doclang/tokenization.py @@ -105,6 +105,23 @@ "", "", "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ' list[str]: return [f'' for value in range(resolution)] +def _hours_tokens() -> list[str]: + """Return concrete ```` tokens for ``N`` in ``[0, 24)``.""" + return [f'' for value in range(24)] + + +def _minutes_tokens() -> list[str]: + """Return concrete ```` tokens for ``N`` in ``[0, 60)``.""" + return [f'' for value in range(60)] + + +def _seconds_tokens() -> list[str]: + """Return concrete ```` tokens for ``N`` in ``[0, 60)``.""" + return [f'' for value in range(60)] + + +def _msecs_tokens() -> list[str]: + """Return concrete ```` tokens for ``N`` in ``{0, 10, 20, ..., 990}``.""" + return [f'' for value in range(0, 1000, 10)] + + def get_special_tokens(*, max_resolution: int = DEFAULT_MAX_RESOLUTION) -> list[str]: """ Return the DocLang special-token vocabulary as a flat list of token strings. @@ -125,4 +162,11 @@ def get_special_tokens(*, max_resolution: int = DEFAULT_MAX_RESOLUTION) -> list[ ``default_resolution@width``/``@height``, or a per-```` override) used across your documents; pass the larger of your x/y resolutions if they differ. """ - return [*_FIXED_TOKENS, *_location_tokens(max_resolution)] + return [ + *_FIXED_TOKENS, + *_location_tokens(max_resolution), + *_hours_tokens(), + *_minutes_tokens(), + *_seconds_tokens(), + *_msecs_tokens(), + ] diff --git a/examples/archive-demo/audio/1.wav b/examples/archive-demo/audio/1.wav new file mode 100644 index 0000000..6f78433 Binary files /dev/null and b/examples/archive-demo/audio/1.wav differ diff --git a/examples/archive-demo/document.xml b/examples/archive-demo/document.xml index f46d588..e0b8371 100644 --- a/examples/archive-demo/document.xml +++ b/examples/archive-demo/document.xml @@ -18,5 +18,21 @@ B + + + + + + + + + Narrator + Hello from page two. + + + + Narrator + That is all for now. + diff --git a/reference/input/examples/track.dclg b/reference/input/examples/track.dclg new file mode 100644 index 0000000..59726f1 --- /dev/null +++ b/reference/input/examples/track.dclg @@ -0,0 +1,16 @@ + + + + + + Alice + Good morning. + Bob + Morning - did you see the report? + + + + + Wrapping up as we pass the hour. + + diff --git a/reference/input/reference.xlsx b/reference/input/reference.xlsx index 7acd09c..49c6ecb 100644 Binary files a/reference/input/reference.xlsx and b/reference/input/reference.xlsx differ diff --git a/spec.md b/spec.md index f45c3fc..5f23677 100644 --- a/spec.md +++ b/spec.md @@ -1547,6 +1547,122 @@ Field region with mixed content: Detailed examples can be seen here: [Form Examples](/examples/form/form-examples.md) +### Tracks + +A `` captures a time-aligned media transcript — subtitles, captions, or diarized speech associated with an audio or video recording. It is a [semantic element](#semantic-elements) and may begin with an [element head](#element-head). + +The body of a `` may begin with a single `` — a representative image for the track as a whole (podcast artwork, a poster, a title card). It has the same shape as `` (an optional element head followed by an optional [``](#src)). + +The rest of the body is a sequence of *cue blocks*. Each cue block is introduced by a `` delimiter (analogous to `` for lists): the first cue block must be a ``, and a cue block spans everything between two sibling `` elements (or until ``). + +A cue block consists of, in order: + +- a **start time** (mandatory): a run of ``, ``, ``, `` in that order. Only `` is required; ``, `` and `` each default to `0` when omitted. `` anchors the run, so two consecutive runs stay unambiguous. +- an **end time** (optional): the same run shape, immediately following the start time. A cue block covers the inclusive interval `[start, end]`. When the end time is omitted it is taken to equal the start time, i.e. the cue block is the single instant `[start, start]` — for example, the timestamp of a `` or a point annotation. +- an optional `` — a chapter or section title (see [Chapters](#chapters) below), handled like [``](#text): it may carry its own element head and inline [formatting](#formatting). +- an optional `` — a still image for the cue block's start time, with an optional element head and an optional [``](#src). Typically a video frame, but equally a slide, a keyframe, or any representative still for that moment. A point sample; valid on any cue block. (For a track-wide image, use `` instead.) +- an optional `