Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions ai_artifacts/project_understanding.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ pycaption/
│ ├── srt.py # SRT format (reader + writer in single file)
│ ├── microdvd.py # MicroDVD format (reader + writer)
│ └── transcript.py # Plain text transcript (writer only, uses nltk)
├── tests/ # Test suite (504 tests)
├── tests/ # Test suite (505 tests)
│ ├── conftest.py # Fixture imports (re-exports from fixtures/)
│ ├── fixtures/ # Pytest fixture modules (inline caption strings)
│ │ ├── scc.py, translated_scc.py, dfxp.py, webvtt.py, srt.py, sami.py, microdvd.py
Expand Down Expand Up @@ -217,6 +217,10 @@ The SAMI reader uses a two-phase approach:
overflow (not 100% as might be expected)
6. **Style filtering**: Writers filter internal keys (e.g. `classes`, `webvtt_positioning`) from
output via format-specific exclusion sets
7. **RP 2052-10 implicit defaults**: When source format's visual default differs from
target format's default, writers explicitly emit the source's alignment. VTT/SRT/SCC
default to center; DFXP/SAMI default to left/start. The `is_positional_anchor` flag
on Layout distinguishes SCC coordinate-system positioning from visual alignment intent.

---

Expand Down Expand Up @@ -286,7 +290,7 @@ Additional workflows for format spec compliance:
### Running Tests Locally

```bash
# Run all tests (504 tests, ~0.5s)
# Run all tests (505 tests, ~0.5s)
python -m pytest tests/ -q

# Run specific format tests
Expand All @@ -299,7 +303,7 @@ python -m pytest tests/ -v
python -m pytest tests/test_webvtt_conversion.py -q
```

- Tests cover all formats (504 tests total)
- Tests cover all formats (505 tests total)
- Fixtures defined in `tests/fixtures/` as pytest session-scoped fixtures
- Each fixture is an inline string containing a complete caption file
- Conversion tests verify round-trip and cross-format fidelity
Expand Down
30 changes: 21 additions & 9 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,27 @@ Changelog
---------
2.3.2
^^^^^^
- DFXP writer: output ``textAlign="center"`` (not ``"start"``) as the
fallback alignment for cues that carry no explicit alignment.
Separates the writer's fallback (CENTER) from the reader's default
region (START) via a new ``DFXP_WRITER_FALLBACK_ALIGNMENT`` constant.

- SAMI reader: apply the SAMI spec default ``text-align: left`` only at
the root layout level (no parent to inherit from). Child layouts
without an explicit text-align now correctly inherit from their parent
stylesheet rather than being forced to left.
- RP 2052-10 compliance for all 25 conversion paths: when source format's
visual default is CENTER (VTT/SRT/SCC) and target default is LEFT/START
(DFXP/SAMI), explicitly emit center alignment. DFXP/SAMI sources retain
their original alignment for round-trip fidelity.

- DFXP writer: suppress ``tts:origin`` from SCC positional layouts
(row/column anchors), emit ``tts:textAlign="center"`` instead.

- WebVTT writer: suppress SCC positional cue settings (``align:left
position:N% line:N% size:N%``). SCC coordinate anchors are not visual
alignment — omitting lets VTT default to center.

- SAMI reader: apply ``text-align: left`` only at the root layout level;
child layouts now inherit from parent rather than being forced to left.

- SAMI writer: emit semantic tags (``<i>``, ``<b>``, ``<u>``) instead of
``<span style="...">``. Add trailing ``&nbsp;`` sync to clear the final
caption at its end time.

- Geometry: add ``is_positional_anchor`` flag to ``Layout`` to distinguish
SCC coordinate-system positioning from visual text alignment.

2.3.1
^^^^^^
Expand Down
4 changes: 2 additions & 2 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
# built documents.
#
# The short X.Y version.
version = "2.3.1"
version = "2.3.2.dev1"
# The full version, including alpha/beta/rc tags.
release = "2.3.1"
release = "2.3.2.dev1"

# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
Expand Down
8 changes: 8 additions & 0 deletions pycaption/dfxp/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,22 @@
"font-size": "1c",
}

# Reader default: DFXP spec mandates START/BOTTOM for round-trip fidelity.
DFXP_DEFAULT_REGION = Layout(
alignment=Alignment(HorizontalAlignmentEnum.START, VerticalAlignmentEnum.BOTTOM)
)

# Writer fallback alignment used when layout is None or SCC positional.
DFXP_WRITER_FALLBACK_ALIGNMENT = Alignment(
HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM
)

# Writer default region for sources without positioning (VTT/SRT);
# uses CENTER per RP 2052-10 rather than the spec's START default.
DFXP_WRITER_DEFAULT_REGION = Layout(
alignment=Alignment(HorizontalAlignmentEnum.CENTER, VerticalAlignmentEnum.BOTTOM)
)

DFXP_DEFAULT_STYLE_ID = "default"
DFXP_DEFAULT_REGION_ID = "bottom"

Expand Down
56 changes: 49 additions & 7 deletions pycaption/dfxp/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
DFXP_DEFAULT_REGION_ID,
DFXP_DEFAULT_STYLE,
DFXP_DEFAULT_STYLE_ID,
DFXP_WRITER_DEFAULT_REGION,
DFXP_WRITER_FALLBACK_ALIGNMENT,
_create_external_alignment,
)
Expand Down Expand Up @@ -290,8 +291,8 @@ def __init__(self, dfxp, caption_set):
def _collect_unique_regions(caption_set, ignore_region):
"""Collect all unique Layout objects from the caption set.

Excludes None and ignore_region (typically the default region) to
avoid duplicate region creation.
Excludes None, ignore_region (typically the default region), and
SCC positional layouts (which map to the default center region).

:type caption_set: CaptionSet
:param ignore_region: a Layout to exclude from the result
Expand All @@ -311,6 +312,9 @@ def _collect_unique_regions(caption_set, ignore_region):

unique_regions.pop(None, None)
unique_regions.pop(ignore_region, None)
for layout in list(unique_regions):
if layout and _is_scc_positional(layout):
unique_regions.pop(layout)
return unique_regions

@staticmethod
Expand Down Expand Up @@ -348,18 +352,38 @@ def _create_unique_regions(unique_layouts, dfxp, id_factory):
layout_section.append(new_region)
return region_map

def _has_null_layouts(self):
"""Check if any caption in the set has no layout_info.

When True, the source format lacks positioning (e.g. VTT/SRT),
so the writer uses CENTER alignment per RP 2052-10.
"""
for lang in self._caption_set.get_languages():
if not self._caption_set.get_layout_info(lang):
return True
for caption in self._caption_set.get_captions(lang):
if not caption.layout_info:
return True
return False

def create_document_regions(self):
"""Create all <region> tags needed by the caption set.

Always creates a default region first, then creates additional
regions for any unique Layout objects found in the caption set.
When captions with no layout exist (VTT/SRT sources), the default
region uses CENTER alignment per RP 2052-10; otherwise it uses the
DFXP spec default (START) for round-trip fidelity.
"""
if self._has_null_layouts():
default_layout = DFXP_WRITER_DEFAULT_REGION
else:
default_layout = DFXP_DEFAULT_REGION

default_region_map = self._create_unique_regions(
[DFXP_DEFAULT_REGION], self._dfxp, lambda: DFXP_DEFAULT_REGION_ID
)
unique_regions = self._collect_unique_regions(
self._caption_set, DFXP_DEFAULT_REGION
[default_layout], self._dfxp, lambda: DFXP_DEFAULT_REGION_ID
)
unique_regions = self._collect_unique_regions(self._caption_set, default_layout)

self._region_map = self._create_unique_regions(
unique_regions, self._dfxp, self._get_new_id
Expand Down Expand Up @@ -403,6 +427,9 @@ def get_positioning_info(
if not layout_info:
layout_info = caption_set.layout_info

if layout_info and _is_scc_positional(layout_info):
layout_info = None

region_id = self._region_map.get(layout_info)
if not region_id:
region_id = DFXP_DEFAULT_REGION_ID
Expand Down Expand Up @@ -466,12 +493,20 @@ def _recreate_style(content, dfxp):
return dfxp_style


def _is_scc_positional(layout):
"""Check if this layout uses a positional anchor (e.g. SCC row/col
coordinates) rather than visual text alignment.
"""
return layout.is_positional_anchor


def _convert_layout_to_attributes(layout):
"""Convert a Layout object to a dict of DFXP region attributes.

Maps origin, extent, padding, alignment, and writing_direction to their
tts: namespace equivalents. Returns default alignment attributes when
layout is None.
layout is None. Detects SCC positional layouts and emits center
alignment per RP 2052-10.

:type layout: Layout | None
:rtype: dict
Expand All @@ -480,6 +515,13 @@ def _convert_layout_to_attributes(layout):
if not layout:
return _create_external_alignment(DFXP_WRITER_FALLBACK_ALIGNMENT)

if _is_scc_positional(layout):
result.update(_create_external_alignment(DFXP_WRITER_FALLBACK_ALIGNMENT))
writing_mode = _WRITING_DIRECTION_TO_DFXP.get(layout.writing_direction)
if writing_mode:
result["tts:writingMode"] = writing_mode
return result

if layout.origin:
result["tts:origin"] = layout.origin.to_xml_attribute()

Expand Down
9 changes: 9 additions & 0 deletions pycaption/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ def __init__(
alignment=None,
webvtt_positioning=None,
writing_direction=None,
is_positional_anchor=False,
inherit_from=None,
):
"""
Expand All @@ -682,6 +683,11 @@ def __init__(
:type writing_direction: WritingDirectionEnum
:param writing_direction: WebVTT vertical writing direction (rl or lr).

:type is_positional_anchor: bool
:param is_positional_anchor: True when the origin/alignment describe a
coordinate-system anchor point (e.g. SCC row/col positioning) rather
than visual text alignment.

:type inherit_from: Layout
:param inherit_from: A Layout with the positioning parameters to be
used if not specified by the positioning arguments,
Expand All @@ -693,6 +699,7 @@ def __init__(
self.alignment = alignment
self.webvtt_positioning = webvtt_positioning
self.writing_direction = writing_direction
self.is_positional_anchor = is_positional_anchor

if inherit_from:
for attr_name in [
Expand Down Expand Up @@ -781,6 +788,7 @@ def as_percentage_of(self, video_width, video_height):
params = {
"alignment": self.alignment,
"writing_direction": self.writing_direction,
"is_positional_anchor": self.is_positional_anchor,
}
for attr_name in ["origin", "extent", "padding"]:
attr = getattr(self, attr_name)
Expand Down Expand Up @@ -844,6 +852,7 @@ def fit_to_screen(self):
padding=self.padding,
alignment=self.alignment,
writing_direction=self.writing_direction,
is_positional_anchor=self.is_positional_anchor,
)

return self
Loading
Loading