flowed inside inline region
+ */
+
+ const adapter = new TTMLAdapter();
+
+ const { data: cues } = adapter.parse(
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content omitted because has both regions
+
+
+
+
+ Content selected because uses only one region
+
+
+
+
+
+ `,
+ );
+
+ expect(cues.length).toBe(1);
+ expect(cues[0]).toMatchObject({
+ content: "Content selected because uses only one region",
+ });
+ }
+
+ {
+ /**
+ * Testing
flowed inside region r1 and
+ *
flowed inside inline region
+ */
+
+ const adapter = new TTMLAdapter();
+
+ adapter.parse(
+ `
+
+
+
+
+
+
+
+
+
+
+ `,
+ );
+ }
+
+ {
+ /**
+ * Testing
flowed inside region r1 and
+ * flowed inside inline region
+ */
+
+ const adapter = new TTMLAdapter();
+
+ adapter.parse(
+ `
+
+
+
+
+
+
+
+
+
+
+
+ Unemitted cue
+
+
+
+
+
+ `,
+ );
+ }
+
+ {
+ /**
+ * Testing flowed inside region r1 and
+ * flowed inside inline region
+ */
+
+ const adapter = new TTMLAdapter();
+
+ adapter.parse(
+ `
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ );
+ }
+ });
+ });
+
+ it("should not consider inline regions defined in middle of other elements (order is important)", () => {
+ /**
+ * Testing flowed inside region r1 and
+ *
flowed inside inline region
+ */
+
+ const adapter = new TTMLAdapter();
+
+ adapter.parse(
+ `
+
+
+
+
+
+
+
+
+
+
+ `,
+ );
+ });
+
+ it.todo("should use inline styles");
+ });
+
+ describe("Regions", () => {
+ it("Should ignore nested elements that have a region attribute different from parent's", () => {
+ const track = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Content of span flowed inside r1
+
+
+ Content of span flowed inside r2
+
+
+
+
+ Content of span not flowed in any region
+
+
+
+ Content of span flowed inside r3
+ Test nested 1
+
+
+
+
+`;
+
+ const adapter = new TTMLAdapter();
+
+ const { data: cues } = adapter.parse(track);
+
+ expect(cues.length).toBe(3);
+
+ expect(cues[0]).toMatchObject({
+ content: "Content of span flowed inside r1",
+ });
+
+ expect(cues[1]).toMatchObject({
+ content: "Content of span flowed inside r3",
+ });
+
+ expect(cues[2]).toMatchObject({
+ content: "Test nested 1",
+ });
+ });
+
+ it("Should ignore elements that have a region attribute when the default region is active", () => {
+ const track = `
+
+
+
+
+ Content of span flowed inside r1
+
+
+ Content of span flowed inside r2
+
+
+
+
+ Content of span el2 flowed in the inline region
+
+
+
+ Content of span flowed inside r3
+
+
+
+
+`;
+
+ const adapter = new TTMLAdapter();
+
+ const { data: cues } = adapter.parse(track);
+
+ expect(cues.length).toBe(1);
+ expect(cues[0]).toMatchObject({
+ content: "Content of span el2 flowed in the inline region",
+ });
+ });
+
+ it.todo(
+ "Should parse and provide region styles" /*() => {
+ const track = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Yep, that's me
+ The span you were looking
+ for.
+
+
+
+`;
+
+ const adapter = new TTMLAdapter();
+
+ adapter.parse(track);
+ }*/,
+ );
+ });
+});
+
+/**
+ * @TODO add tests for
+ *
+ * - div with multiple regions (only the first should be used)
+ * - p with multiple regions (only the first should be used)
+ * - Nested div and p with a region each, both should be applied to the outcoming cue
+ */
diff --git a/packages/ttml-adapter/specs/Kleene.spec.mjs b/packages/ttml-adapter/specs/Kleene.spec.mjs
new file mode 100644
index 0000000..853d840
--- /dev/null
+++ b/packages/ttml-adapter/specs/Kleene.spec.mjs
@@ -0,0 +1,150 @@
+import { describe, it, expect } from "@jest/globals";
+import * as Kleene from "../lib/Parser/Tags/Representation/kleene.js";
+
+describe("Kleene", () => {
+ /**
+ * @param {string} nodeName
+ * @returns {boolean}
+ */
+
+ function matches(nodeName) {
+ return true;
+ }
+
+ describe("ZeroOrMore (*)", () => {
+ it("should match multiple sequential instances of the same node", () => {
+ const operator = Kleene.zeroOrMore({
+ nodeName: "test",
+ destinationFactory: () => [],
+ matches,
+ });
+
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test")).toBe(true);
+ });
+
+ it("should delegate the matching function to a child operator", () => {
+ const operator = Kleene.zeroOrMore(
+ Kleene.or(
+ {
+ nodeName: "testA",
+ destinationFactory: () => [],
+ matches(nodeName) {
+ return nodeName === "testA";
+ },
+ },
+ {
+ nodeName: "testB",
+ destinationFactory: () => [],
+ matches(nodeName) {
+ return nodeName === "testB";
+ },
+ },
+ ),
+ );
+
+ expect(operator.matches("testC")).toBe(false);
+ expect(operator.matches("testA")).toBe(true);
+ expect(operator.matches("testB")).toBe(true);
+ });
+ });
+
+ describe("OneOrMore (+)", () => {
+ it("should match multiple sequential instances of the same node", () => {
+ const operator = Kleene.oneOrMore({
+ nodeName: "test",
+ destinationFactory: () => [],
+ matches,
+ });
+
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test")).toBe(true);
+ });
+
+ it("should throw an error if the first element of its kind is not found", () => {
+ const operator = Kleene.oneOrMore({
+ nodeName: "test",
+ destinationFactory: () => [],
+ matches,
+ });
+
+ expect(() => operator.matches("test1")).toThrowError();
+ });
+
+ it("should not throw an error if an element is found now and not found later", () => {
+ const operator = Kleene.oneOrMore({
+ nodeName: "test",
+ destinationFactory: () => [],
+ matches,
+ });
+
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test1")).toBe(false);
+ });
+
+ it("should delegate the matching function to a child operator", () => {
+ const operator = Kleene.oneOrMore(
+ Kleene.or(
+ {
+ nodeName: "testA",
+ destinationFactory: () => [],
+ matches(nodeName) {
+ return nodeName === "testA";
+ },
+ },
+ {
+ nodeName: "testB",
+ destinationFactory: () => [],
+ matches() {
+ return false;
+ },
+ },
+ ),
+ );
+
+ expect(operator.matches("testA")).toBe(true);
+ expect(operator.matches("testB")).toBe(false);
+ expect(operator.matches("testC")).toBe(false);
+ });
+ });
+
+ describe("ZeroOrOne (?)", () => {
+ it("should match only the first instance of a node", () => {
+ const operator = Kleene.zeroOrOne({
+ nodeName: "test",
+ destinationFactory: () => [],
+ matches,
+ });
+
+ expect(operator.matches("test")).toBe(true);
+ expect(operator.matches("test")).toBe(false);
+ });
+
+ it("should delegate the matching function to a child operator", () => {
+ const operator = Kleene.zeroOrOne(
+ Kleene.or(
+ {
+ nodeName: "testA",
+ destinationFactory: () => [],
+ matches(nodeName) {
+ return nodeName === "testA";
+ },
+ },
+ {
+ nodeName: "testB",
+ destinationFactory: () => [],
+ matches() {
+ return true;
+ },
+ },
+ ),
+ );
+
+ expect(operator.matches("testA")).toBe(true);
+ expect(operator.matches("testB")).toBe(false);
+ expect(operator.matches("testC")).toBe(false);
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/NodeTree.spec.mjs b/packages/ttml-adapter/specs/NodeTree.spec.mjs
new file mode 100644
index 0000000..51b4d9a
--- /dev/null
+++ b/packages/ttml-adapter/specs/NodeTree.spec.mjs
@@ -0,0 +1,58 @@
+import { describe, it, expect } from "@jest/globals";
+import { NodeTree } from "../lib/Parser/Tags/NodeTree.js";
+
+/**
+ * @typedef {import("../lib/Parser/Token.js").Token} Token
+ */
+
+describe("NodeTree", () => {
+ /** @type {NodeTree} */
+ let nodeTree;
+
+ beforeEach(() => {
+ nodeTree = new NodeTree();
+ });
+
+ it("should keep the last pushed element as current when another one is tracked", () => {
+ /** This is useful to track empty tags */
+
+ nodeTree.push({ content: 5 });
+ nodeTree.track({ content: 6 });
+
+ expect(nodeTree.currentNode).toMatchObject({
+ content: {
+ content: 5,
+ },
+ children: [
+ {
+ content: {
+ content: 6,
+ },
+ children: [],
+ },
+ ],
+ });
+ });
+
+ it("should upgrade the last used node on the memory when an element is popped out", () => {
+ nodeTree.push({ content: 5 });
+ nodeTree.push({ content: 6 });
+
+ nodeTree.pop();
+
+ expect(nodeTree.currentNode).toMatchObject({ content: { content: 5 } });
+ expect(nodeTree.tree).toMatchObject({
+ content: {
+ content: 5,
+ },
+ children: [
+ {
+ content: {
+ content: 6,
+ },
+ children: [],
+ },
+ ],
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/Parser.spec.mjs b/packages/ttml-adapter/specs/Parser.spec.mjs
new file mode 100644
index 0000000..bc76d02
--- /dev/null
+++ b/packages/ttml-adapter/specs/Parser.spec.mjs
@@ -0,0 +1,315 @@
+// @ts-check
+
+import { describe, expect, it } from "@jest/globals";
+import { parseCue } from "../lib/Parser/parseCue.js";
+import { createScope } from "../lib/Parser/Scope/Scope.js";
+import { createTimeContext } from "../lib/Parser/Scope/TimeContext.js";
+import { TokenType } from "../lib/Parser/Token.js";
+import { createDocumentContext } from "../lib/Parser/Scope/DocumentContext.js";
+import { createTemporalActiveContext } from "../lib/Parser/Scope/TemporalActiveContext.js";
+
+describe("parseCue", () => {
+ it("should be coherent with anonymous span", () => {
+ const Hello = {
+ content: {
+ content: "Hello",
+ attributes: {},
+ type: TokenType.STRING,
+ },
+ children: [],
+ };
+
+ const Guten = {
+ content: {
+ content: "Guten ",
+ attributes: {},
+ type: TokenType.STRING,
+ },
+ children: [],
+ };
+
+ const Tag = {
+ content: {
+ content: "Tag",
+ attributes: {},
+ type: TokenType.STRING,
+ },
+ children: [],
+ };
+
+ const NamedSpan1 = {
+ content: {
+ content: "span",
+ attributes: {},
+ type: TokenType.START_TAG,
+ },
+ children: [
+ Guten,
+ {
+ content: {
+ content: "span",
+ attributes: {},
+ type: TokenType.START_TAG,
+ },
+ children: [Tag],
+ },
+ ],
+ };
+
+ const Allo = {
+ content: {
+ content: "Allo",
+ attributes: {},
+ type: TokenType.STRING,
+ },
+ children: [],
+ };
+
+ const Paragraph = {
+ content: {
+ content: "p",
+ attributes: {
+ timeContainer: "seq",
+ "xml:id": "par-01",
+ },
+ type: TokenType.START_TAG,
+ },
+ children: [Hello, NamedSpan1, Allo],
+ };
+
+ Hello.parent = Paragraph;
+ Allo.parent = Paragraph;
+ NamedSpan1.parent = Paragraph;
+ Guten.parent = NamedSpan1;
+ Tag.parent = NamedSpan1.children[0];
+
+ /**
+ *
+ * Hello
+ *
+ * Guten
+ *
+ * Tag
+ *
+ *
+ * Allo
+ *
+ */
+
+ const scope = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTemporalActiveContext({}),
+ );
+
+ const parsed = parseCue(Paragraph, scope);
+
+ expect(parsed).toBeInstanceOf(Array);
+ expect(parsed.length).toBe(4);
+ expect(parsed[0]).toMatchObject({
+ content: "Hello",
+ startTime: 0,
+ endTime: 0,
+ });
+ expect(parsed[1]).toMatchObject({
+ content: "Guten ",
+ startTime: 0,
+ endTime: Infinity,
+ });
+ expect(parsed[2]).toMatchObject({
+ content: "Tag",
+ startTime: 0,
+ endTime: Infinity,
+ });
+ expect(parsed[3]).toMatchObject({
+ content: "Allo",
+ startTime: 0,
+ endTime: 0,
+ });
+ });
+
+ it("should be return timestamps", () => {
+ const Paragraph1 = {
+ content: {
+ content: "p",
+ attributes: {
+ "xml:id": "par-01",
+ },
+ type: TokenType.START_TAG,
+ },
+ children: [
+ {
+ content: {
+ type: TokenType.START_TAG,
+ content: "span",
+ attributes: {
+ begin: "0s",
+ },
+ },
+ children: [
+ {
+ content: {
+ type: TokenType.STRING,
+ content: "Lorem",
+ attributes: {},
+ },
+ children: [],
+ },
+ ],
+ },
+ {
+ content: {
+ type: TokenType.START_TAG,
+ content: "span",
+ attributes: {
+ begin: "1s",
+ },
+ },
+ children: [
+ {
+ content: {
+ type: TokenType.STRING,
+ content: "ipsum",
+ attributes: {},
+ },
+ children: [],
+ },
+ ],
+ },
+ {
+ content: {
+ type: TokenType.START_TAG,
+ content: "span",
+ attributes: {
+ begin: "2s",
+ },
+ },
+ children: [
+ {
+ content: {
+ type: TokenType.STRING,
+ content: "dolor",
+ attributes: {},
+ },
+ children: [],
+ },
+ ],
+ },
+ {
+ content: {
+ type: TokenType.START_TAG,
+ content: "span",
+ attributes: {
+ begin: "3s",
+ },
+ },
+ children: [
+ {
+ content: {
+ type: TokenType.STRING,
+ content: "sit",
+ attributes: {},
+ },
+ children: [],
+ },
+ ],
+ },
+ ],
+ };
+
+ const scope = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ begin: "0s",
+ end: "25s",
+ }),
+ );
+ const parsed = parseCue(Paragraph1, scope);
+
+ expect(parsed).toBeInstanceOf(Array);
+ expect(parsed.length).toBe(4);
+ expect(parsed[0]).toMatchObject({
+ content: "Lorem",
+ startTime: 0,
+ endTime: 25000,
+ });
+ expect(parsed[1]).toMatchObject({
+ content: "ipsum",
+ startTime: 1000,
+ endTime: 25000,
+ });
+ expect(parsed[2]).toMatchObject({
+ content: "dolor",
+ startTime: 2000,
+ endTime: 25000,
+ });
+ expect(parsed[3]).toMatchObject({
+ content: "sit",
+ startTime: 3000,
+ endTime: 25000,
+ });
+ });
+});
+
+describe("Cell Resolution", () => {
+ it("should default if not available", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ it("should default if its values are incorrect", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ it("should be used it available and correct", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ it("should be allowed to be converted to pixels", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ /**
+ * @TODO Add tests for font-size cell conversion to pixel
+ */
+
+ /**
+ * @TODO Add tests for font-size percentage
+ */
+});
+
+describe("Lengths", () => {
+ /**
+ * Add tests for
unit measure with the dedicated parser
+ */
+});
+
+describe("Styling", () => {
+ describe("Chaining Referential Styling", () => {
+ it("should be applied if one IDREF is specified through style attribute", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ it("should be applied if multiple IDREFs are specified through style attribute", () => {
+ /**
+ * @TODO
+ */
+ });
+
+ it("should throw if there's a loop in styling referencing chain", () => {
+ /**
+ * @TODO
+ */
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/Scope.spec.mjs b/packages/ttml-adapter/specs/Scope.spec.mjs
new file mode 100644
index 0000000..cc36b8d
--- /dev/null
+++ b/packages/ttml-adapter/specs/Scope.spec.mjs
@@ -0,0 +1,263 @@
+import { describe, it, expect, jest } from "@jest/globals";
+import { createScope } from "../lib/Parser/Scope/Scope.js";
+import { createTimeContext, readScopeTimeContext } from "../lib/Parser/Scope/TimeContext.js";
+import {
+ createStyleContainerContext,
+ readScopeStyleContainerContext,
+} from "../lib/Parser/Scope/StyleContainerContext.js";
+import { createDocumentContext } from "../lib/Parser/Scope/DocumentContext.js";
+
+/**
+ * @typedef {import("../lib/Parser/Scope/Scope.js").Context} Context
+ * @template {string[]} ContentType
+ */
+
+/**
+ * @typedef {Context & { content: ContentType; exposed_mockedSymbol?: symbol }} MockedContextExtension
+ * @template {string[]} ContentType
+ */
+
+/**
+ * @callback mockedContext
+ * @param {Array} [contents]
+ * @returns {MockedContextExtension | null}
+ */
+
+describe("Scope and contexts", () => {
+ describe("Scope", () => {
+ it("should accept several contexts as input", () => {
+ const mockedContext = /** @type {Context} */ () => {
+ const mockedContextSymbol = Symbol("mockedContextSymbol");
+
+ const contextFactory = (_scope) => {
+ contextFactory.exposedContext = {
+ identifier: mockedContextSymbol,
+ get content() {
+ return [];
+ },
+ get exposed_mockedSymbol() {
+ return mockedContextSymbol;
+ },
+ };
+
+ return contextFactory.exposedContext;
+ };
+
+ contextFactory.exposedSymbol = mockedContextSymbol;
+
+ return contextFactory;
+ };
+
+ const contexts = [mockedContext(), mockedContext(), mockedContext(), mockedContext()];
+ const scope = createScope(undefined, ...contexts);
+
+ expect(scope.getAllContexts().length).toBe(4);
+ expect(scope.getContextByIdentifier(contexts[0].exposedSymbol)).toEqual(
+ contexts[0].exposedContext,
+ );
+ expect(scope.getContextByIdentifier(contexts[1].exposedSymbol)).toEqual(
+ contexts[1].exposedContext,
+ );
+ expect(scope.getContextByIdentifier(contexts[2].exposedSymbol)).toEqual(
+ contexts[2].exposedContext,
+ );
+ expect(scope.getContextByIdentifier(contexts[3].exposedSymbol)).toEqual(
+ contexts[3].exposedContext,
+ );
+ });
+
+ it("should merge two contexts with the same identifier", () => {
+ const mockedContextSymbol = Symbol("mockedContextSymbol");
+ const mockedContext = (contents) => {
+ return (_scope) => {
+ return {
+ identifier: mockedContextSymbol,
+ get content() {
+ return contents;
+ },
+
+ /**
+ *
+ * @param {MockedContextExtension} context
+ */
+
+ mergeWith(context) {
+ contents.push(...context.content);
+ },
+ };
+ };
+ };
+
+ const contexts = [mockedContext(["a", "b", "c", "d"]), mockedContext(["e", "f", "g", "h"])];
+ const scope = createScope(undefined, contexts[0]);
+
+ scope.addContext(contexts[1]);
+
+ expect(scope.getAllContexts().length).toBe(1);
+ expect(scope.getContextByIdentifier(mockedContextSymbol).content).toEqual([
+ "a",
+ "b",
+ "c",
+ "d",
+ "e",
+ "f",
+ "g",
+ "h",
+ ]);
+ });
+ });
+
+ describe("TimeContext", () => {
+ /**
+ * - On an element specifying both "dur" and "end", should win the
+ * Math.min(dur, end - begin). Test both cases.
+ */
+ it("should return the minimum between end and dur, on the same context", () => {
+ const scope = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ end: "10s",
+ dur: "20s",
+ }),
+ );
+
+ expect(readScopeTimeContext(scope).endTime).toBe(10000);
+ });
+
+ it("should return the minimum between end and dur, on the different contexts", () => {
+ const scope1 = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ end: "20s",
+ }),
+ );
+
+ const scope2 = createScope(
+ scope1,
+ createTimeContext({
+ dur: "15s",
+ }),
+ );
+
+ expect(readScopeTimeContext(scope2).endTime).toBe(15000);
+ });
+
+ it("should return the minimum between end - begin and dur plus the startTime, when begin is specified", () => {
+ const scope1 = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ begin: "5s",
+ end: "20s",
+ }),
+ );
+
+ const scope2 = createScope(
+ scope1,
+ createTimeContext({
+ dur: "16s",
+ }),
+ );
+
+ expect(readScopeTimeContext(scope2).endTime).toBe(20000);
+ });
+
+ it("should return infinity if neither dur and end are specified", () => {
+ const scope = createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ timeContainer: "par",
+ }),
+ );
+
+ expect(readScopeTimeContext(scope).endTime).toBe(Infinity);
+ });
+
+ it("should return 0 if neither dur and end are specified but cues are sequential", () => {
+ const scope1 = createScope(
+ createScope(
+ undefined,
+ createDocumentContext({}),
+ createTimeContext({
+ timeContainer: "seq",
+ }),
+ ),
+ createTimeContext({
+ begin: "0s",
+ }),
+ );
+
+ expect(readScopeTimeContext(scope1).endTime).toBe(0);
+ });
+ });
+
+ describe("RegionContext", () => {});
+
+ describe("StyleContext", () => {
+ it("should merge multiple style contexts on the same scope, by giving priority to the last. Styles are merged", () => {
+ const scope = createScope(
+ undefined,
+ createStyleContainerContext({
+ t1: {
+ "xml:id": "t1",
+ "tts:textColor": "blue",
+ "tts:backgroundColor": "rose",
+ },
+ }),
+ createStyleContainerContext({
+ t2: {
+ "xml:id": "t2",
+ "tts:textColor": "blue",
+ },
+ }),
+ );
+
+ // expect(readScopeStyleContainerContext(scope).styles).toMatchObject(
+ // new Map([
+ // [
+ // "t2",
+ // {
+ // attributes: {
+ // "tts:textColor": "blue",
+ // "tts:backgroundColor": "rose",
+ // },
+ // },
+ // ],
+ // ]),
+ // );
+ });
+
+ it("should be able to iterate all the parent styles", () => {
+ const scope1 = createScope(
+ undefined,
+ createStyleContainerContext({
+ t1: {
+ "xml:id": "t1",
+ "tts:textColor": "blue",
+ },
+ }),
+ );
+
+ const scope2 = createScope(
+ scope1,
+ createStyleContainerContext({
+ t2: {
+ "xml:id": "t2",
+ "tts:textColor": "rose",
+ },
+ }),
+ );
+
+ // expect(readScopeStyleContainerContext(scope2).styles).toBeInstanceOf(Map);
+ // expect(readScopeStyleContainerContext(scope2).styles).toMatchObject(
+ // new Map([
+ // ["t1", { id: "t1", attributes: { "tts:textColor": "blue" } }],
+ // ["t2", { id: "t2", attributes: { "tts:textColor": "rose" } }],
+ // ]),
+ // );
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/TimeBase/clock.spec.mjs b/packages/ttml-adapter/specs/TimeBase/clock.spec.mjs
new file mode 100644
index 0000000..714cdba
--- /dev/null
+++ b/packages/ttml-adapter/specs/TimeBase/clock.spec.mjs
@@ -0,0 +1,129 @@
+import { describe, expect, it } from "@jest/globals";
+import {
+ getMillisecondsByClockTime,
+ getMillisecondsByOffsetTime,
+ getMillisecondsByWallClockTime,
+} from "../../lib/Parser/TimeBase/Clock.js";
+
+describe("When timeBase is 'clock'", () => {
+ it("should throw when converting in milliseconds if frames or subframes are passed, when converting to clock-time", () => {
+ expect(() =>
+ getMillisecondsByClockTime([
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ {
+ value: 25,
+ metric: "frames",
+ },
+ {
+ value: 20,
+ metric: "subframes",
+ },
+ ]),
+ ).toThrow();
+ });
+
+ it("should not throw when converting in milliseconds if frames or subframes are undefined, when converting to clock-time", () => {
+ expect(() =>
+ getMillisecondsByClockTime([
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ {
+ value: undefined,
+ metric: "frames",
+ },
+ {
+ value: undefined,
+ metric: "subframes",
+ },
+ ]),
+ ).not.toThrow();
+
+ expect(() =>
+ getMillisecondsByClockTime([
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ undefined,
+ undefined,
+ ]),
+ ).not.toThrow();
+ });
+
+ it("should include only hours, minutes, seconds and fractions when converting to clock-time", () => {
+ expect(
+ getMillisecondsByClockTime([
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ undefined,
+ undefined,
+ ]),
+ ).toBe(4530500);
+ });
+
+ it("should return the date in milliseconds when converting to wallclock-time", () => {
+ const currentTime = Date.now();
+ expect(getMillisecondsByWallClockTime({ value: new Date().getTime(), metric: "date" })).toBe(
+ currentTime,
+ );
+ });
+
+ it("should return the milliseconds offset when converting to offset-time and metric is not a ticks", () => {
+ expect(getMillisecondsByOffsetTime({ value: 10.5, metric: "s" }, {})).toBe(10500);
+ expect(getMillisecondsByOffsetTime({ value: 10, metric: "s" }, {})).toBe(10000);
+ });
+
+ it("should return the milliseconds offset when converting to offset-time and metric is ticks", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 10_010_000.1, metric: "t" },
+ { "ttp:tickRate": 10_000_000 },
+ ),
+ ).toBe(1002);
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 10_010_000, metric: "t" },
+ { "ttp:tickRate": 10_000_000 },
+ ),
+ ).toBe(1001);
+ });
+});
diff --git a/packages/ttml-adapter/specs/TimeBase/media.spec.mjs b/packages/ttml-adapter/specs/TimeBase/media.spec.mjs
new file mode 100644
index 0000000..5e160c5
--- /dev/null
+++ b/packages/ttml-adapter/specs/TimeBase/media.spec.mjs
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "@jest/globals";
+import {
+ getMillisecondsByClockTime,
+ getMillisecondsByOffsetTime,
+ getMillisecondsByWallClockTime,
+} from "../../lib/Parser/TimeBase/Media.js";
+
+describe("When timeBase is 'media'", () => {
+ it("should return the milliseconds with frames when converting clock-time", () => {
+ expect(
+ getMillisecondsByClockTime(
+ [
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ {
+ value: 25,
+ metric: "frames",
+ },
+ {
+ value: 0.2,
+ metric: "subframes",
+ },
+ ],
+ {
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ "ttp:frameRateMultiplier": 1000 / 1001,
+ },
+ ),
+ ).toBe(4_531_509.3416666667);
+ });
+
+ it("should use a referenceBegin parameter when converting clock-time", () => {
+ expect(
+ getMillisecondsByClockTime(
+ [
+ {
+ value: 1,
+ metric: "hours",
+ },
+ {
+ value: 15,
+ metric: "minutes",
+ },
+ {
+ value: 30.5,
+ metric: "seconds",
+ },
+ {
+ value: 25,
+ metric: "frames",
+ },
+ {
+ value: 0.2,
+ metric: "subframes",
+ },
+ ],
+ {
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ "ttp:frameRateMultiplier": 1000 / 1001,
+ },
+ 50000,
+ ),
+ ).toBe(4_581_509.3416666667);
+ });
+
+ it("should throw when converting wallclock-time", () => {
+ expect(() =>
+ getMillisecondsByWallClockTime({ value: new Date().getTime(), metric: "date" }),
+ ).toThrow();
+ });
+
+ it("should return the milliseconds when converting an offset-time with ticks metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 24, metric: "t" },
+ {
+ "ttp:tickRate": 60,
+ },
+ ),
+ ).toBe(400);
+ });
+
+ it("should return the milliseconds when converting an offset-time with frame metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 24.5, metric: "f" },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(1021);
+ });
+
+ it("should return the milliseconds when converting an offset-time with milliseconds metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 15.3, metric: "ms" },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(15.3);
+ });
+
+ it("should return the milliseconds when converting an offset-time with seconds metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 15.3, metric: "s" },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(15300);
+ });
+
+ it("should return the milliseconds when converting an offset-time with minutes metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 15.3, metric: "m" },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(918000);
+ });
+
+ it("should return the milliseconds when converting an offset-time with hours metric", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ { value: 15.3, metric: "h" },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(55080000);
+ });
+
+ it("should return the milliseconds when converting an offset-time with unknown metric, by treating it as hours", () => {
+ expect(
+ getMillisecondsByOffsetTime(
+ {
+ value: 15.3,
+ // @ts-expect-error
+ metric: "k",
+ },
+ {
+ "ttp:tickRate": 60,
+ "ttp:frameRate": 24,
+ "ttp:subFrameRate": 1, // fallback
+ },
+ ),
+ ).toBe(55080000);
+ });
+});
diff --git a/packages/ttml-adapter/specs/TimeBase/smpte.spec.mjs b/packages/ttml-adapter/specs/TimeBase/smpte.spec.mjs
new file mode 100644
index 0000000..6e62bfd
--- /dev/null
+++ b/packages/ttml-adapter/specs/TimeBase/smpte.spec.mjs
@@ -0,0 +1,153 @@
+import { describe, expect, it } from "@jest/globals";
+import { getMillisecondsByWallClockTime } from "../../lib/Parser/TimeBase/SMPTE.js";
+import { getMillisecondsByOffsetTime } from "../../lib/Parser/TimeBase/SMPTE.js";
+import { getMillisecondsByClockTime } from "../../lib/Parser/TimeBase/SMPTE.js";
+
+describe("When timeBase is 'smpte'", () => {
+ describe("non-drop dropMode", () => {
+ it("should return the milliseconds with frames, subframes, hours, minutes and seconds, when converting clock-time", () => {
+ /**
+ * No frames dropped, one hour at 29.97fps = 107,892 frames
+ */
+ expect(
+ getMillisecondsByClockTime(
+ [
+ { value: 1, metric: "hours" },
+ { value: 0, metric: "minutes" },
+ { value: 0, metric: "seconds" },
+ { value: 0, metric: "frames" },
+ { value: 0, metric: "subframes" },
+ ],
+ {
+ "ttp:dropMode": "nonDrop",
+ "ttp:frameRate": 29.97,
+ "ttp:subFrameRate": 1,
+ "ttp:frameRateMultiplier": 1,
+ },
+ ),
+ ).toBe(3600000);
+ });
+ });
+
+ describe("drop-NTSC dropMode", () => {
+ it("should return the milliseconds with frames, subframes, hours, minutes and seconds, when converting clock-time", () => {
+ /**
+ * NDF Timecode at 00:59:56.12 should be
+ * equal to one clock time hour, which
+ * is equal to 01:00:00.00 with NTSC DF.
+ */
+
+ {
+ const nonDropFrameTimeCode = getMillisecondsByClockTime(
+ [
+ { value: 0, metric: "hours" },
+ { value: 59, metric: "minutes" },
+ { value: 56, metric: "seconds" },
+ { value: 12, metric: "frames" },
+ { value: 0, metric: "subframes" },
+ ],
+ {
+ "ttp:dropMode": "nonDrop",
+ "ttp:frameRate": 29.97,
+ "ttp:subFrameRate": 1,
+ "ttp:frameRateMultiplier": 1,
+ },
+ );
+
+ const ntscDropFrameTimeCode = getMillisecondsByClockTime(
+ [
+ { value: 1, metric: "hours" },
+ { value: 0, metric: "minutes" },
+ { value: 0, metric: "seconds" },
+ { value: 0, metric: "frames" },
+ { value: 0, metric: "subframes" },
+ ],
+ {
+ "ttp:dropMode": "dropNTSC",
+ "ttp:frameRate": 29.97,
+ "ttp:subFrameRate": 1,
+ "ttp:frameRateMultiplier": 1,
+ },
+ );
+
+ expect(Math.trunc(nonDropFrameTimeCode / 1000)).toBe(3596);
+ expect(Math.trunc(nonDropFrameTimeCode / 1000)).toBe(
+ Math.trunc(ntscDropFrameTimeCode / 1000),
+ );
+ }
+
+ {
+ const nonDropFrameTimeCode = getMillisecondsByClockTime(
+ [
+ { value: 11, metric: "hours" },
+ { value: 59, metric: "minutes" },
+ { value: 16, metric: "seconds" },
+ { value: 0, metric: "frames" },
+ { value: 0, metric: "subframes" },
+ ],
+ {
+ "ttp:dropMode": "nonDrop",
+ "ttp:frameRate": 29.97,
+ "ttp:subFrameRate": 1,
+ "ttp:frameRateMultiplier": 1,
+ },
+ );
+
+ const ntscDropFrameTimeCode = getMillisecondsByClockTime(
+ [
+ { value: 12, metric: "hours" },
+ { value: 0, metric: "minutes" },
+ { value: 0, metric: "seconds" },
+ { value: 0, metric: "frames" },
+ { value: 0, metric: "subframes" },
+ ],
+ {
+ "ttp:dropMode": "dropNTSC",
+ "ttp:frameRate": 29.97,
+ "ttp:subFrameRate": 1,
+ "ttp:frameRateMultiplier": 1,
+ },
+ );
+
+ expect(Math.trunc(nonDropFrameTimeCode / 1000)).toBe(43156);
+ expect(Math.trunc(nonDropFrameTimeCode / 1000)).toBe(
+ Math.trunc(ntscDropFrameTimeCode / 1000),
+ );
+
+ const ntscDropFrameTimeCodeSeconds = ntscDropFrameTimeCode / 1000;
+ const twelveHoursInSeconds = 3600 * 12;
+
+ expect(Math.trunc(twelveHoursInSeconds - ntscDropFrameTimeCodeSeconds)).toBe(43);
+ }
+ });
+ });
+
+ describe("drop-PAL dropMode", () => {
+ /**
+ * dropPAL is for something used only in Brazil and which we are
+ * not even sure will ever be available, after 20 years of standard,
+ * on the web.
+ *
+ * Further more, on the web there is no full explanation of how M-PAL
+ * works. As it shares technical details with NTSC, by knowing the
+ * exact calculation, bandwidth or frequency and lines / columns of
+ * the standards, one could technically retrieve the information
+ * and create a test. Thing that I wasn't. So, if you want to
+ * create it, you'll deserve all my gratitude.
+ */
+
+ it("should return the milliseconds with frames, subframes, hours, minutes and seconds, when converting clock-time", () => {});
+ });
+
+ it("should throw when converting wallclock-time", () => {
+ expect(() =>
+ getMillisecondsByWallClockTime({ value: new Date().getTime(), metric: "date" }),
+ ).toThrow();
+ });
+
+ it("should throw when converting offset-time", () => {
+ expect(() =>
+ getMillisecondsByOffsetTime({ value: new Date().getTime(), metric: "f" }),
+ ).toThrow();
+ });
+});
diff --git a/packages/ttml-adapter/specs/TimeExpressions.spec.mjs b/packages/ttml-adapter/specs/TimeExpressions.spec.mjs
new file mode 100644
index 0000000..f1ebc81
--- /dev/null
+++ b/packages/ttml-adapter/specs/TimeExpressions.spec.mjs
@@ -0,0 +1,144 @@
+import { describe, expect, it } from "@jest/globals";
+import { matchClockTimeExpression } from "../lib/Parser/TimeExpressions/matchers/clockTime.js";
+import { matchOffsetTimeExpression } from "../lib/Parser/TimeExpressions/matchers/offsetTime.js";
+import { matchWallClockTimeExpression } from "../lib/Parser/TimeExpressions/matchers/wallclockTime.js";
+
+// hours : minutes : seconds (. fraction | : frames ('.' sub-frames)? )?
+describe("Clock Time conversion to time matcher", () => {
+ it("Should not convert 'hh' and 'hh:mm'", () => {
+ expect(matchClockTimeExpression("10")).toBe(null);
+ expect(matchClockTimeExpression("10:20")).toBe(null);
+ });
+
+ it("Should convert 'hh:mm:ss'", () => {
+ const [hoursUnit1, minutesUnit1, secondsUnit1] = matchClockTimeExpression("22:57:10");
+
+ expect(hoursUnit1).toMatchObject({ value: 22, metric: "hours" });
+ expect(minutesUnit1).toMatchObject({ value: 57, metric: "minutes" });
+ expect(secondsUnit1).toMatchObject({ value: 10, metric: "seconds" });
+ });
+
+ it("Should convert 'hh:mm:ss.fraction'", () => {
+ const [hoursUnit1, minutesUnit1, secondsUnit1] = matchClockTimeExpression("22:57:10.300");
+
+ expect(hoursUnit1).toMatchObject({
+ value: 22,
+ metric: "hours",
+ });
+ expect(minutesUnit1).toMatchObject({
+ value: 57,
+ metric: "minutes",
+ });
+ expect(secondsUnit1).toMatchObject({
+ value: 10.3,
+ metric: "seconds",
+ });
+
+ const [hoursUnit2, minutesUnit2, secondsUnit2] = matchClockTimeExpression("22:57:10.033");
+
+ expect(hoursUnit2).toMatchObject({
+ value: 22,
+ metric: "hours",
+ });
+ expect(minutesUnit2).toMatchObject({
+ value: 57,
+ metric: "minutes",
+ });
+ expect(secondsUnit2).toMatchObject({
+ value: 10.033,
+ metric: "seconds",
+ });
+ });
+
+ it("Should convert 'hh:mm:ss:frames'", () => {
+ const [hoursUnit, minutesUnit, secondsUnit, framesUnit] =
+ matchClockTimeExpression("22:57:10:8762231.20");
+
+ expect(hoursUnit).toMatchObject({
+ value: 22,
+ metric: "hours",
+ });
+ expect(minutesUnit).toMatchObject({
+ value: 57,
+ metric: "minutes",
+ });
+ expect(secondsUnit).toMatchObject({
+ value: 10,
+ metric: "seconds",
+ });
+ expect(framesUnit).toMatchObject({
+ value: 8762231,
+ metric: "frames",
+ });
+ });
+
+ it("Should convert 'hh:mm:ss:frames.sub-frames'", () => {
+ const [hoursUnit, minutesUnit, secondsUnit, framesUnit, subFramesUnit] =
+ matchClockTimeExpression("22:57:10:8762231.20");
+
+ expect(hoursUnit).toMatchObject({
+ value: 22,
+ metric: "hours",
+ });
+ expect(minutesUnit).toMatchObject({
+ value: 57,
+ metric: "minutes",
+ });
+ expect(secondsUnit).toMatchObject({
+ value: 10,
+ metric: "seconds",
+ });
+ expect(framesUnit).toMatchObject({
+ value: 8762231,
+ metric: "frames",
+ });
+ expect(subFramesUnit).toMatchObject({
+ value: 20,
+ metric: "subframes",
+ });
+ });
+});
+
+/** time-count fraction? metric */
+describe("Offset time conversion to time matcher", () => {
+ it("Should convert 'time-count fraction? metric'", () => {
+ expect(matchOffsetTimeExpression("10f")).toMatchObject({ value: 10.0, metric: "f" });
+ expect(matchOffsetTimeExpression("10h")).toMatchObject({ value: 10.0, metric: "h" });
+ expect(matchOffsetTimeExpression("10.500f")).toMatchObject({ value: 10.5, metric: "f" });
+ expect(matchOffsetTimeExpression("10.050f")).toMatchObject({ value: 10.05, metric: "f" });
+ });
+});
+
+/** "wallclock(" ? ( date-time | wall-time | date ) ? ")" */
+describe("Wallclock time conversion to time matcher", () => {
+ it("should convert 'wallclock(\"? (date-time) ?\")'", () => {
+ expect(matchWallClockTimeExpression(`wallclock(" 2020-05-10T22:10:57 ")`)).toMatchObject({
+ value: new Date("2020-05-10T19:10:57.000Z").getTime(),
+ metric: "date",
+ });
+
+ expect(matchWallClockTimeExpression(`wallclock(" 2021-06-10T22:10")`)).toMatchObject({
+ value: new Date("2021-06-10T19:10:00.000Z").getTime(),
+ metric: "date",
+ });
+ });
+
+ it("should convert 'wallclock(\"? (wall-time) ?\")'", () => {
+ expect(matchWallClockTimeExpression(`wallclock("22:10:57 ")`)).toMatchObject({
+ value: new Date("1970-01-01T21:10:57.000Z").getTime(),
+ metric: "date",
+ });
+
+ expect(matchWallClockTimeExpression(`wallclock("22:10 ")`)).toMatchObject({
+ value: new Date("1970-01-01T21:10:00.000Z").getTime(),
+ metric: "date",
+ });
+ });
+
+ it("should convert 'wallclock(\"? (date) ?\")'", () => {
+ expect(matchWallClockTimeExpression(`wallclock(" 2021-10-19 ")`)).toMatchObject({
+ value: new Date(2021, 9, 19).getTime(),
+ metric: "date",
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/Tokenizer.spec.mjs b/packages/ttml-adapter/specs/Tokenizer.spec.mjs
new file mode 100644
index 0000000..c9b2b34
--- /dev/null
+++ b/packages/ttml-adapter/specs/Tokenizer.spec.mjs
@@ -0,0 +1,282 @@
+// @ts-check
+
+import { describe, it, expect } from "@jest/globals";
+import { Tokenizer } from "../lib/Parser/Tokenizer.js";
+import { Token, TokenType } from "../lib/Parser/Token.js";
+
+describe("Tokenizer", () => {
+ describe("Tokenization", () => {
+ it("should return a ProcessingInstruction, if available", () => {
+ const content = `
+
+ `;
+ const tokenizer = new Tokenizer(content);
+ const result = tokenizer.nextToken();
+
+ expect(result).not.toBeNull();
+ expect(result).toBeInstanceOf(Token);
+ expect(result?.type).toBe(TokenType.PROCESSING_INSTRUCTION);
+
+ /**
+ * We expect these two tests to fail, if one day we'll start
+ * to support ProcessingInstruction validation
+ */
+ expect(result?.content).toBe("ProcessingInstruction");
+ expect(result?.attributes).toEqual({});
+ });
+
+ it("should return a ValidationEntity, if available", () => {
+ // Space on purpose
+ const content = `
+
+
+
+ `;
+ const tokenizer = new Tokenizer(content);
+ const result = tokenizer.nextToken();
+
+ expect(result).not.toBeNull();
+ expect(result).toBeInstanceOf(Token);
+ expect(result?.type).toBe(TokenType.VALIDATION_ENTITY);
+
+ /**
+ * We expect these two tests to fail, if one day we'll start
+ * to support ValidationEntity validation
+ */
+ expect(result?.content).toBe("ValidationEntity");
+ expect(result?.attributes).toEqual({});
+ });
+
+ it("should return a CData, if available", () => {
+ const content = `
+ Hello, world!]]>
+ `;
+ const tokenizer = new Tokenizer(content);
+ const result = tokenizer.nextToken();
+
+ expect(result).not.toBeNull();
+ expect(result).toBeInstanceOf(Token);
+ expect(result?.type).toBe(TokenType.CDATA);
+
+ /**
+ * We expect these two tests to fail, if one day we'll start
+ * to support CDATA validation
+ */
+ expect(result?.content).toBe("CDATA Tag");
+ expect(result?.attributes).toEqual({});
+ });
+
+ it("should return a Comment, if available", () => {
+ const content = `
+
+ `;
+ const tokenizer = new Tokenizer(content);
+ const result = tokenizer.nextToken();
+
+ expect(result).not.toBeNull();
+ expect(result).toBeInstanceOf(Token);
+ expect(result?.type).toBe(TokenType.COMMENT);
+ expect(result?.content).toBe("Comment Tag");
+ expect(result?.attributes).toEqual({});
+ });
+
+ it("should return an empty tag with all of its attributes", () => {
+ const content = `
+
+ `;
+ const tokenizer = new Tokenizer(content);
+ const result = tokenizer.nextToken();
+
+ expect(result).not.toBeNull();
+ expect(result).toBeInstanceOf(Token);
+ expect(result?.type).toBe(TokenType.START_TAG);
+ expect(result?.content).toBe("style");
+ expect(result?.attributes).toEqual({
+ "xml:id": "s1",
+ "tts:color": "white",
+ "tts:fontFamily": "proportionalSansSerif",
+ "tts:fontSize": "22px",
+ "tts:textAlign": "center",
+ });
+ });
+
+ it("should return a couple of tags with all of their attributes (with children)", () => {
+ /**
+ * For XML specification, having tabs and spaces
+ * in strings (like after the parenthesis), is allowed.
+ *
+ * So, when testing, we might check them too. Yeah, they
+ * would suck in a content.
+ * Also having a bracket on a new line would suck, but it
+ * is more readable here.
+ */
+ const content = `
+
+ (alarm beeping,Jane gasps)
+
+ `;
+ const tokenizer = new Tokenizer(content);
+
+ /**
+ * @type {Token[]}
+ */
+ const tokens = [];
+
+ /**
+ * @type {Token | null}
+ */
+
+ let token;
+
+ while ((token = tokenizer.nextToken()) !== null) {
+ tokens.push(token);
+ }
+
+ const [
+ pStartToken,
+ stringToken1,
+ strongStartToken,
+ stringToken2,
+ strongEndToken,
+ stringToken3,
+ pEndToken,
+ ] = tokens;
+
+ expect(pStartToken).not.toBeNull();
+ expect(pStartToken).toBeInstanceOf(Token);
+ expect(pStartToken?.type).toBe(TokenType.START_TAG);
+ expect(pStartToken?.content).toBe("p");
+ expect(pStartToken?.attributes).toMatchObject({
+ begin: "342010001t",
+ end: "365370003t",
+ region: "region_00",
+ "tts:extent": "35.00% 5.33%",
+ "tts:origin": "30.00% 79.29%",
+ "xml:id": "subtitle1",
+ });
+
+ expect(stringToken1).not.toBeNull();
+ expect(stringToken1).toBeInstanceOf(Token);
+ expect(stringToken1?.type).toBe(TokenType.STRING);
+ expect(stringToken1?.content).toBe("(alarm beeping,");
+ expect(stringToken1?.attributes).toMatchObject({});
+
+ expect(strongStartToken).not.toBeNull();
+ expect(strongStartToken).toBeInstanceOf(Token);
+ expect(strongStartToken?.type).toBe(TokenType.START_TAG);
+ expect(strongStartToken?.content).toBe("strong");
+ expect(strongStartToken?.attributes).toMatchObject({});
+
+ expect(stringToken2).not.toBeNull();
+ expect(stringToken2).toBeInstanceOf(Token);
+ expect(stringToken2?.type).toBe(TokenType.STRING);
+ expect(stringToken2?.content).toBe("Jane gasps");
+ expect(stringToken2?.attributes).toMatchObject({});
+
+ expect(strongEndToken).not.toBeNull();
+ expect(strongEndToken).toBeInstanceOf(Token);
+ expect(strongEndToken?.type).toBe(TokenType.END_TAG);
+ expect(strongEndToken?.content).toBe("strong");
+ expect(strongEndToken?.attributes).toMatchObject({});
+
+ expect(stringToken3).not.toBeNull();
+ expect(stringToken3).toBeInstanceOf(Token);
+ expect(stringToken3?.type).toBe(TokenType.STRING);
+ expect(stringToken3?.content).toBe(")");
+ expect(stringToken3?.attributes).toMatchObject({});
+
+ expect(pEndToken).not.toBeNull();
+ expect(pEndToken).toBeInstanceOf(Token);
+ expect(pEndToken?.type).toBe(TokenType.END_TAG);
+ expect(pEndToken?.content).toBe("p");
+ expect(pEndToken?.attributes).toMatchObject({});
+ });
+
+ it("should return a couple of tags with all of their attributes (no children)", () => {
+ const content = ``;
+ const tokenizer = new Tokenizer(content);
+ const [startToken, endToken] = [tokenizer.nextToken(), tokenizer.nextToken()];
+
+ expect(startToken).not.toBeNull();
+ expect(startToken).toBeInstanceOf(Token);
+ expect(startToken?.type).toBe(TokenType.START_TAG);
+ expect(startToken?.content).toBe("p");
+ expect(startToken?.attributes).toEqual({
+ begin: "342010001t",
+ "xml:id": "subtitle1",
+ });
+
+ expect(endToken).not.toBeNull();
+ expect(endToken).toBeInstanceOf(Token);
+ expect(endToken?.type).toBe(TokenType.END_TAG);
+ expect(endToken?.content).toBe("p");
+ expect(endToken?.attributes).toEqual({});
+ });
+
+ it("should accept a token with an attribute surrounded by spaces", () => {
+ const content = ``;
+ const tokenizer = new Tokenizer(content);
+ const startToken = tokenizer.nextToken();
+
+ expect(startToken).not.toBeNull();
+ expect(startToken).toBeInstanceOf(Token);
+ expect(startToken?.type).toBe(TokenType.START_TAG);
+ expect(startToken?.attributes["begin"]).not.toBeUndefined();
+ expect(startToken?.attributes["begin"]).toBe("342010001t");
+ });
+
+ it("should accept a token with an attribute on a new line and should get rid of useless spaces and whitelines", () => {
+ const content = ``;
+ const tokenizer = new Tokenizer(content);
+ const startToken = tokenizer.nextToken();
+
+ expect(startToken).not.toBeNull();
+ expect(startToken).toBeInstanceOf(Token);
+ expect(startToken?.type).toBe(TokenType.START_TAG);
+ expect(startToken?.attributes["begin"]).not.toBeUndefined();
+ expect(startToken?.attributes["begin"]).toBe("342010001t");
+ });
+
+ it("should return a string without leading and trailing padding before a tag closing", () => {
+ const content = `
+
+
+
+
+
+ `;
+
+ const tokenizer = new Tokenizer(content);
+
+ let token;
+ let tokens = [];
+
+ while ((token = tokenizer.nextToken())) {
+ tokens.push(token);
+ }
+
+ const [_tt, _body, _div, _p, _region, _regionEnd, stringToken] = tokens;
+
+ expect(stringToken).toMatchObject({
+ content: "Some Content",
+ });
+ });
+ });
+});
diff --git a/packages/ttml-adapter/specs/Visitor.spec.mjs b/packages/ttml-adapter/specs/Visitor.spec.mjs
new file mode 100644
index 0000000..3da995b
--- /dev/null
+++ b/packages/ttml-adapter/specs/Visitor.spec.mjs
@@ -0,0 +1,44 @@
+import { describe, it, expect, beforeEach } from "@jest/globals";
+import { createVisitor } from "../lib/Parser/Tags/Representation/visitor.js";
+import { createNode } from "../lib/Parser/Tags/Representation/NodeRepresentation.js";
+import * as Kleene from "../lib/Parser/Tags/Representation/kleene.js";
+
+describe("Visitor", () => {
+ it("should return the first node that matches", () => {
+ const visitor = createVisitor(
+ createNode(null, () => [
+ Kleene.zeroOrOne(createNode("test1")),
+ Kleene.oneOrMore(createNode("test2")),
+ ]),
+ );
+
+ expect(visitor.match("test2")).not.toBeNull();
+ expect(visitor.match("test1")).toBeNull();
+ expect(visitor.match("test2")).not.toBeNull();
+ expect(visitor.match("test2")).not.toBeNull();
+ });
+
+ it("should navigate to the next node and perform a match check on it's destinations and back", () => {
+ const visitor = createVisitor(
+ createNode(null, () => [
+ Kleene.zeroOrOne(createNode("test1")),
+ Kleene.oneOrMore(createNode("test2", () => [Kleene.oneOrMore(createNode("test3"))])),
+ Kleene.oneOrMore(createNode("test4", () => [Kleene.zeroOrOne(createNode("test4"))])),
+ ]),
+ );
+
+ const dest = visitor.match("test2");
+ visitor.navigate(dest);
+
+ expect(visitor.match("test3")).not.toBeNull();
+
+ visitor.back();
+
+ const dest1 = visitor.match("test4");
+ expect(dest1).not.toBeNull();
+
+ visitor.navigate(dest1);
+
+ expect(visitor.match("test4")).not.toBeNull();
+ });
+});
diff --git a/packages/ttml-adapter/specs/jsconfig.json b/packages/ttml-adapter/specs/jsconfig.json
new file mode 100644
index 0000000..7e098b1
--- /dev/null
+++ b/packages/ttml-adapter/specs/jsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true
+ },
+ "include": ["./**/*.spec.mjs"]
+}
diff --git a/packages/ttml-adapter/src/Adapter.ts b/packages/ttml-adapter/src/Adapter.ts
new file mode 100644
index 0000000..224cd77
--- /dev/null
+++ b/packages/ttml-adapter/src/Adapter.ts
@@ -0,0 +1,659 @@
+import { BaseAdapter, CueNode } from "@sub37/server";
+import { MissingContentError } from "./MissingContentError.js";
+import { Tokenizer } from "./Parser/Tokenizer.js";
+import { createScope, type Scope } from "./Parser/Scope/Scope.js";
+import { createTimeContext } from "./Parser/Scope/TimeContext.js";
+import { createStyleContainerContext } from "./Parser/Scope/StyleContainerContext.js";
+import type { RegionContainerContextState } from "./Parser/Scope/RegionContainerContext.js";
+import {
+ createRegionContainerContext,
+ readScopeRegionContext,
+} from "./Parser/Scope/RegionContainerContext.js";
+import { parseCue } from "./Parser/parseCue.js";
+import { createDocumentContext, readScopeDocumentContext } from "./Parser/Scope/DocumentContext.js";
+import { Token, TokenType } from "./Parser/Token.js";
+import { NodeTree } from "./Parser/Tags/NodeTree.js";
+import {
+ createTemporalActiveContext,
+ readScopeTemporalActiveContext,
+} from "./Parser/Scope/TemporalActiveContext.js";
+import { createVisitor } from "./Parser/Tags/Representation/Visitor.js";
+import { RepresentationTree } from "./Parser/Tags/Representation/RepresentationTree.js";
+import type { NodeRepresentation } from "./Parser/Tags/Representation/NodeRepresentation.js";
+
+const nodeAttributesSymbol = Symbol("nodeAttributesSymbol");
+const nodeScopeSymbol = Symbol("nodeScopeSymbol");
+const nodeMatchSymbol = Symbol("nodeMatchSymbol");
+
+enum NodeAttributes {
+ NO_ATTRS /***/ = 0b000,
+ IGNORED /****/ = 0b001,
+}
+
+interface NodeWithAttributes {
+ [nodeAttributesSymbol]: NodeAttributes;
+}
+
+interface NodeWithScope {
+ [nodeScopeSymbol]?: Scope;
+}
+
+interface NodeWithDestinationMatch {
+ [nodeMatchSymbol]?: NodeRepresentation;
+}
+
+function isNodeIgnored(
+ node: NodeWithAttributes,
+): node is NodeAttributes & { [nodeAttributesSymbol]: NodeAttributes.IGNORED } {
+ return Boolean(node[nodeAttributesSymbol] & NodeAttributes.IGNORED);
+}
+
+function createNodeWithAttributes(
+ node: NodeType,
+ attributes: NodeAttributes,
+): NodeType & NodeWithAttributes {
+ return Object.create(node, {
+ [nodeAttributesSymbol]: {
+ value: attributes,
+ writable: true,
+ },
+ });
+}
+
+function appendNodeAttributes(
+ node: NodeType & NodeWithAttributes,
+ attributes: NodeAttributes,
+): NodeType & NodeWithAttributes {
+ if (typeof node[nodeAttributesSymbol] === "undefined") {
+ throw new Error("Cannot add attributes to node that has none.");
+ }
+
+ node[nodeAttributesSymbol] ^= attributes;
+ return node;
+}
+
+function createNodeWithScope(
+ node: NodeType,
+ scope: Scope,
+): NodeType & NodeWithScope {
+ return Object.create(node, {
+ [nodeScopeSymbol]: {
+ value: scope,
+ },
+ });
+}
+
+function createNodeWithDestinationMatch(
+ node: NodeType,
+ destination: NodeRepresentation,
+): NodeType & NodeWithDestinationMatch {
+ return Object.create(node, {
+ [nodeMatchSymbol]: {
+ value: destination,
+ },
+ });
+}
+
+function isNodeMatched(node: object): boolean {
+ return nodeMatchSymbol in node;
+}
+
+/**
+ * @see https://www.w3.org/TR/2018/REC-ttml2-20181108/#element-vocab-group-table
+ */
+
+const BLOCK_CLASS_ELEMENT = ["div", "p"] as const;
+type BLOCK_CLASS_ELEMENT = typeof BLOCK_CLASS_ELEMENT;
+
+function isBlockClassElement(content: string): content is BLOCK_CLASS_ELEMENT[number] {
+ return BLOCK_CLASS_ELEMENT.includes(content as BLOCK_CLASS_ELEMENT[number]);
+}
+
+const INLINE_CLASS_ELEMENT = ["span", "br"] as const;
+type INLINE_CLASS_ELEMENT = typeof INLINE_CLASS_ELEMENT;
+
+function isInlineClassElement(content: string): content is INLINE_CLASS_ELEMENT[number] {
+ return INLINE_CLASS_ELEMENT.includes(content as INLINE_CLASS_ELEMENT[number]);
+}
+
+const LAYOUT_CLASS_ELEMENT = ["region"] as const;
+type LAYOUT_CLASS_ELEMENT = typeof LAYOUT_CLASS_ELEMENT;
+
+function isLayoutClassElement(content: string): content is LAYOUT_CLASS_ELEMENT[number] {
+ return LAYOUT_CLASS_ELEMENT.includes(content as LAYOUT_CLASS_ELEMENT[number]);
+}
+
+export default class TTMLAdapter extends BaseAdapter {
+ public static override get supportedType() {
+ return "application/ttml+xml";
+ }
+
+ public override parse(rawContent: string): BaseAdapter.ParseResult {
+ if (!rawContent) {
+ return BaseAdapter.ParseResult(undefined, [
+ {
+ error: new MissingContentError(),
+ failedChunk: "",
+ isCritical: true,
+ },
+ ]);
+ }
+
+ let cues: CueNode[] = [];
+ let treeScope: Scope = createScope(undefined);
+
+ const nodeTree = new NodeTree<
+ Token & NodeWithAttributes & NodeWithScope & NodeWithDestinationMatch
+ >();
+ const representationVisitor = createVisitor(RepresentationTree);
+ const tokenizer = new Tokenizer(rawContent);
+
+ let token: Token = null;
+
+ while ((token = tokenizer.nextToken())) {
+ switch (token.type) {
+ case TokenType.STRING: {
+ if (!nodeTree.currentNode) {
+ continue;
+ }
+
+ if (isNodeIgnored(nodeTree.currentNode.content)) {
+ continue;
+ }
+
+ // Treating strings as Anonymous spans
+ const destinationMatch = representationVisitor.match("span");
+
+ if (!destinationMatch) {
+ continue;
+ }
+
+ nodeTree.track(createNodeWithAttributes(token, NodeAttributes.NO_ATTRS));
+ break;
+ }
+
+ case TokenType.START_TAG: {
+ if (nodeTree.currentNode && isNodeIgnored(nodeTree.currentNode.content)) {
+ continue;
+ }
+
+ const destinationMatch = representationVisitor.match(token.content);
+
+ if (!destinationMatch) {
+ /**
+ * Even if token does not respect it parent relatioship,
+ * we still add it to the queue to mark its end later.
+ *
+ * We don't want to track it inside the tree, instead,
+ * because we are going to ignore it.
+ */
+
+ nodeTree.push(createNodeWithAttributes(token, NodeAttributes.IGNORED));
+ continue;
+ }
+
+ representationVisitor.navigate(destinationMatch);
+
+ if (token.content === "tt") {
+ if (readScopeDocumentContext(treeScope)) {
+ /**
+ * @TODO Change in a fatal error;
+ */
+ throw new Error("Malformed TTML track: multiple were found.");
+ }
+
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.NO_ATTRS,
+ ),
+ );
+
+ treeScope.addContext(
+ createDocumentContext(nodeTree.currentNode.content.attributes || {}),
+ );
+ continue;
+ }
+
+ /**
+ * **LITTLE IMPLEMENTATION NOTE**
+ *
+ * In the context of building the ISD (Intermediary Synchronic Document),
+ * thing that we don't strictly do, by not following the provided algorithm,
+ * [associate region] procedure at 11.3.1.3, specifies a series of
+ * conditions for which a content element can flow in a out-of-line region.
+ *
+ * Third point states what follows:
+ *
+ * A content element is associated with a region "if the element contains
+ * a descendant element that specifies a region attribute [...], then the
+ * element is associated with the region referenced by that attribute;"
+ *
+ * By saying that we have a deep span with a region attribute with no
+ * parent above it with a region attribute, we would end up with it to get
+ * pruned because parent doesn't have a region and would therefore get
+ * pruned itself.
+ *
+ * Region completion will happen in the END_TAG, if not ignored.
+ */
+
+ const temporalActiveContext = readScopeTemporalActiveContext(treeScope);
+ const regionContext = readScopeRegionContext(treeScope);
+
+ const { currentNode } = nodeTree;
+ const currentTagName = currentNode.content.content;
+
+ if (isLayoutClassElement(token.content)) {
+ const isParentLayout = currentTagName === "layout";
+
+ if (isParentLayout) {
+ /**
+ * We cannot use an out-of-line region element if
+ * it doesn't have an id, isn't it? ¯\_(ツ)_/¯
+ */
+
+ if (!token.attributes["xml:id"]) {
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.IGNORED,
+ ),
+ );
+ continue;
+ }
+ } else {
+ const temporalActiveRegionId = temporalActiveContext?.regionIdRef;
+
+ if (temporalActiveRegionId) {
+ /**
+ * @example
+ *
+ * |--------------------------------|--------------------------------------------------------------|
+ * | Before [process inline region] | After [process inline region] |
+ * |--------------------------------|--------------------------------------------------------------|
+ * | ```xml | ```xml |
+ * | | |
+ * | | |
+ * | | |
+ * | | |
+ * | | |
+ * | | |
+ * | |
|
+ * |
| |
+ * |
...
|
...
|
+ * |
|
|
+ * | | |
+ * | | |
+ * | ``` | ``` |
+ * |________________________________|______________________________________________________________|
+ *
+ * Therefore, for the [associate region] procedure, the div will end up
+ * being pruned, because of a different region.
+ *
+ * @see https://w3c.github.io/ttml2/#procedure-process-inline-regions
+ */
+
+ appendNodeAttributes(nodeTree.currentNode.content, NodeAttributes.IGNORED);
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ NodeAttributes.IGNORED,
+ ),
+ );
+ continue;
+ }
+ }
+ }
+
+ const canElementFlowInRegions =
+ isBlockClassElement(token.content) ||
+ (isInlineClassElement(token.content) && token.content !== "br");
+
+ if (!canElementFlowInRegions) {
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.NO_ATTRS,
+ ),
+ );
+ break;
+ }
+
+ // p and spans get elaborated in a different place
+ const canElementOwnTimingAttributes = token.content === "div" || token.content === "body";
+ const hasTimingAttributes =
+ canElementOwnTimingAttributes &&
+ ("begin" in token.attributes ||
+ "end" in token.attributes ||
+ "dur" in token.attributes ||
+ "timeContainer" in token.attributes);
+
+ if (hasTimingAttributes) {
+ treeScope = createScope(
+ treeScope,
+ createTimeContext({
+ begin: token.attributes["begin"],
+ end: token.attributes["end"],
+ dur: token.attributes["dur"],
+ timeContainer: token.attributes["timeContainer"],
+ }),
+ );
+ }
+
+ /**
+ * Checking if there's a region collision between a parent and a children.
+ * Regions will be evaluated when its end tag is received.
+ */
+
+ if (token.attributes["region"]) {
+ if (!regionContext?.regions.length) {
+ /**
+ * "Furthermore, if no out-of-line region is specified,
+ * then the region attribute must not be specified on
+ * any content element in the document instance."
+ */
+
+ /**
+ * @TODO Stardard defines this as a "must", so it
+ * could be marked as an error.
+ *
+ * Should we?
+ */
+
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.IGNORED,
+ ),
+ );
+ continue;
+ }
+
+ if (
+ temporalActiveContext?.region &&
+ temporalActiveContext.regionIdRef !== token.attributes["region"]
+ ) {
+ /**
+ * @example (out-of-line regions definitions in head omitted)
+ * Inspecting , but
+ *
+ * ```
+ *
+ *
+ *
+ * ...
+ * ```
+ */
+
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.IGNORED,
+ ),
+ );
+ continue;
+ }
+
+ const flowedRegion = regionContext.getRegionById(token.attributes["region"]);
+
+ if (flowedRegion) {
+ treeScope = createScope(
+ treeScope,
+ /**
+ * @TODO timing attributes on a region are temporal details
+ * for which "the region is eligible for activation".
+ *
+ * This means that we could have an element E with both
+ * region R and longer timing range [ET1, ET2]. Such
+ * element could technically overflow the time span of
+ * region in both -x and +x like ET1 ≤ RT1 ≤ RT2 ≤ ET2.
+ *
+ * However, right now we don't have a mean to split the
+ * cues in this sense.
+ */
+ createTimeContext({
+ begin: flowedRegion.timingAttributes["begin"],
+ dur: flowedRegion.timingAttributes["dur"],
+ end: flowedRegion.timingAttributes["end"],
+ timeContainer: flowedRegion.timingAttributes["timeContainer"],
+ }),
+ createTemporalActiveContext({
+ regionIDRef: token.attributes["region"],
+ stylesIDRefs: [],
+ }),
+ );
+ }
+ } else if (
+ !temporalActiveContext?.regionIdRef &&
+ regionContext?.regions.length &&
+ isInlineClassElement(token.content)
+ ) {
+ /**
+ * [construct intermediate document] procedure replicates the whole subtree
+ * after for each active region.
+ *
+ * ISD construction should be seen as a set of replicated documents for each
+ * region. This means that some elements are always shared.
+ *
+ * [associate region] defines on it's 3rd rule, that an element (e.g. ) should
+ * get ignored if none of its children have a region.
+ *
+ * However, a parent can get ignored as well if it has no children because all
+ * of them have been already pruned. And this is where we go to act.
+ *
+ * Due to the fact that we parse the tree linearly, without doing multiple steps
+ * and without building an actual ISD, we cannot know if any element but inline
+ * elements (`span`s and `br`s) will get pruned or replicated under a certain
+ * region.
+ *
+ * Ofc, having the default region active (no region defined in the head) is fine
+ * and allows all the elements.
+ */
+
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.IGNORED,
+ ),
+ );
+ continue;
+ }
+
+ nodeTree.push(
+ createNodeWithAttributes(
+ createNodeWithScope(
+ createNodeWithDestinationMatch(token, destinationMatch),
+ treeScope,
+ ),
+ NodeAttributes.NO_ATTRS,
+ ),
+ );
+ break;
+ }
+
+ case TokenType.END_TAG: {
+ if (!nodeTree.currentNode) {
+ continue;
+ }
+
+ if (nodeTree.currentNode.content.content !== token.content) {
+ continue;
+ }
+
+ if (isNodeMatched(nodeTree.currentNode.content)) {
+ // Pruned by rules, not by destination mismatching
+ representationVisitor.back();
+
+ const currentNode = nodeTree.currentNode.content;
+ const parentNode = nodeTree.currentNode.parent?.content;
+ const didScopeUpgrade = parentNode
+ ? currentNode[nodeScopeSymbol] !== parentNode[nodeScopeSymbol]
+ : true;
+
+ if (didScopeUpgrade && treeScope.parent) {
+ treeScope = treeScope.parent;
+ }
+ }
+
+ if (isNodeIgnored(nodeTree.currentNode.content)) {
+ nodeTree.pop();
+ break;
+ }
+
+ if (token.content === "tt") {
+ nodeTree.pop();
+ break;
+ }
+
+ const parentNode = nodeTree.currentNode.parent.content.content;
+
+ /**
+ * Processing inline regions to be saved.
+ * Remember: inline regions end before we
+ * can process a cue (paragraph) content
+ */
+
+ if (
+ isBlockClassElement(parentNode) ||
+ (isInlineClassElement(parentNode) && parentNode !== "br")
+ ) {
+ if (token.content === "region") {
+ /**
+ * if the `[attributes]` information item property of R does not include
+ * an `xml:id` attribute, then add an implied `xml:id` attribute with a
+ * generated value _ID_ that is unique within the scope of the TTML
+ * document instance;
+ *
+ * otherwise, let _ID_ be the value of the `xml:id` attribute of R;
+ */
+
+ const regionId =
+ token.attributes["xml:id"] ||
+ `i_region-${
+ nodeTree.currentNode.content.attributes["xml:id"] ||
+ nodeTree.currentNode.parent.content.attributes["xml:id"]
+ }`;
+
+ const { children } = nodeTree.currentNode;
+
+ const inlineRegion: RegionContainerContextState = {
+ attributes: Object.create(token.attributes, {
+ "xml:id": {
+ value: regionId,
+ },
+ }),
+ children,
+ };
+
+ treeScope = createScope(
+ treeScope,
+ createRegionContainerContext([inlineRegion]),
+ createTemporalActiveContext({
+ regionIDRef: regionId,
+ }),
+ );
+
+ break;
+ }
+ }
+
+ const currentTag = nodeTree.currentNode.content.content;
+
+ /**
+ * Processing [out-of-line region]
+ * @see https://w3c.github.io/ttml2/#terms-out-of-line-region
+ */
+
+ const currentElement = nodeTree.pop();
+
+ if (currentTag === "layout") {
+ const { children } = currentElement;
+
+ const localRegions: RegionContainerContextState[] = [];
+
+ for (const { content: tokenContent, children: regionChildren } of children) {
+ if (tokenContent.content !== "region") {
+ continue;
+ }
+
+ localRegions.push({ attributes: tokenContent.attributes, children: regionChildren });
+ }
+
+ treeScope.addContext(createRegionContainerContext(localRegions));
+
+ break;
+ }
+
+ /**
+ * Processing out-of-line styles
+ */
+
+ if (currentTag === "styling") {
+ const { children } = currentElement;
+
+ const styleTags = children.reduce>>(
+ (acc, { content: token }) => {
+ if (token.content !== "style") {
+ return acc;
+ }
+
+ if (!token.attributes["xml:id"]) {
+ return acc;
+ }
+
+ acc[token.attributes["xml:id"]] = token.attributes;
+ return acc;
+ },
+ {},
+ );
+
+ treeScope.addContext(createStyleContainerContext(styleTags));
+
+ break;
+ }
+
+ if (
+ isBlockClassElement(currentTag) ||
+ (isInlineClassElement(currentTag) && currentTag !== "br")
+ ) {
+ if (currentTag === "p") {
+ const node = currentElement;
+ cues.push(...parseCue(node, currentElement.content[nodeScopeSymbol]));
+ }
+
+ break;
+ }
+ }
+ }
+ }
+
+ if (!readScopeDocumentContext(treeScope)) {
+ throw new Error(`Document failed to parse: element is apparently missing.`);
+ }
+
+ return BaseAdapter.ParseResult(cues, []);
+ }
+}
diff --git a/packages/ttml-adapter/src/MissingContentError.ts b/packages/ttml-adapter/src/MissingContentError.ts
new file mode 100644
index 0000000..d9e4ffd
--- /dev/null
+++ b/packages/ttml-adapter/src/MissingContentError.ts
@@ -0,0 +1,14 @@
+/**
+ * @TODO this error is replicated also inside WEBVTT adapter
+ * but moving this on Server requires a breaking change
+ * because it would require adapters to have a specific version
+ * of server.
+ */
+
+export class MissingContentError extends Error {
+ constructor() {
+ super();
+ this.name = "MissingContentError";
+ this.message = "Cannot parse content. Empty content received.";
+ }
+}
diff --git a/packages/ttml-adapter/src/Parser/Scope/DocumentContext.ts b/packages/ttml-adapter/src/Parser/Scope/DocumentContext.ts
new file mode 100644
index 0000000..c1e454d
--- /dev/null
+++ b/packages/ttml-adapter/src/Parser/Scope/DocumentContext.ts
@@ -0,0 +1,296 @@
+import type { Context, ContextFactory, Scope } from "./Scope";
+import { onMergeSymbol } from "./Scope.js";
+import type { TimeDetails } from "../TimeBase/index.js";
+import { getSplittedLinearWhitespaceValues } from "../Units/lwsp.js";
+import { asNumbers, preventZeros } from "../Units/number.js";
+
+const documentContextSymbol = Symbol("document");
+
+export interface DocumentAttributes extends TimeDetails {
+ "ttp:displayAspectRatio"?: number[];
+ "ttp:pixelAspectRatio"?: number[];
+ "ttp:cellResolution"?: number[];
+ "tts:extent"?: number[];
+}
+
+interface DocumentContext extends Context> {
+ attributes: DocumentAttributes;
+}
+
+declare module "./Scope" {
+ interface ContextDictionary {
+ [documentContextSymbol]: DocumentContext;
+ }
+}
+
+export function createDocumentContext(
+ rawAttributes: Record,
+): ContextFactory {
+ return function (scope: Scope) {
+ const previousDocumentContext = readScopeDocumentContext(scope);
+
+ if (previousDocumentContext) {
+ throw new Error(
+ "A document context is already existing. One document context is allowed per time",
+ );
+ }
+
+ const attributes = parseDocumentSupportedAttributes(rawAttributes);
+
+ /**
+ * tts:extent on tt right now is not supported.
+ *
+ * Although it can only have as values "auto", "contain",
+ * or "px px" (or "auto auto"), that would
+ * imply we can resize our rendering area, which we are not
+ * yet able to because adapters do not communicate any
+ * "document details" to the renderer, such how much wide
+ * should be the rendering area.
+ *
+ * If ever we'll be able to do that, we'll add a new style
+ * context to the scope like this:
+ *
+ * ```js
+ * if (attributes["tts:extent"]) {
+ * scope.addContext(
+ * createStyleContext({
+ * "tts:extent": rawAttributes["tts:extent"],
+ * }),
+ * );
+ * }
+ * ```
+ */
+
+ return {
+ parent: undefined,
+ identifier: documentContextSymbol,
+ get args() {
+ return rawAttributes;
+ },
+ [onMergeSymbol](context) {
+ throw new Error(
+ "Document context merge is not allowed. Only one document context can exists at the same time.",
+ );
+ },
+ attributes,
+ };
+ };
+}
+
+export function readScopeDocumentContext(scope: Scope): DocumentContext | undefined {
+ return scope.getContextByIdentifier(documentContextSymbol);
+}
+
+function parseDocumentSupportedAttributes(
+ attributes: Record,
+): Readonly {
+ /**
+ * "If not specified, the frame rate must be considered
+ * to be equal to some application defined frame rate,
+ * or if no application defined frame rate applies,
+ * then thirty (30) frames per second.
+ *
+ * If specified, then the frame rate must be greater
+ * than zero (0)."
+ *
+ * @see https://w3c.github.io/ttml2/#parameter-attribute-frameRate
+ *
+ * Application is not allowed to specify a custom fallback value (yet?).
+ */
+ const frameRate = getFrameRateResolvedValue(attributes["ttp:frameRate"]);
+
+ /**
+ * "If not specified, the sub-frame rate must be considered
+ * to be equal to some application defined sub-frame rate,
+ * or if no application defined sub-frame rate applies, then one (1).
+ * If specified, then the sub-frame rate must be greater than zero (0)"
+ *
+ * @see https://w3c.github.io/ttml2/#parameter-attribute-subFrameRate
+ *
+ * Application is not allowed to specify a custom fallback value (yet?).
+ */
+ const subFrameRate = getFrameRateResolvedValue(attributes["ttp:subFrameRate"]);
+
+ const tickRate = getTickRateResolvedValue(attributes["ttp:tickRate"], frameRate, subFrameRate);
+
+ const extent = asNumbers(getSplittedLinearWhitespaceValues(attributes["tts:extent"]));
+
+ return Object.freeze({
+ /**
+ * Time Attributes
+ */
+ "ttp:dropMode": getDropModeResolvedValue(attributes["ttp:dropMode"]),
+ "ttp:frameRate": frameRate || 30,
+ "ttp:subFrameRate": subFrameRate || 1,
+ "ttp:frameRateMultiplier": getFrameRateMultiplerResolvedValue(
+ attributes["ttp:frameRateMultiplier"],
+ ),
+ "ttp:tickRate": tickRate,
+ "ttp:timeBase": getTimeBaseResolvedValue(attributes["ttp:timeBase"]),
+ "ttp:markerMode": getMarkerModeResolvedValue(attributes["ttp:markerMode"]),
+
+ /**
+ * Container attributes
+ */
+ "ttp:displayAspectRatio": asNumbers(
+ getSplittedLinearWhitespaceValues(attributes["ttp:displayAspectRatio"]),
+ ),
+ "ttp:pixelAspectRatio": getPixelAspectRatio(
+ asNumbers(getSplittedLinearWhitespaceValues(attributes["ttp:pixelAspectRatio"])),
+ extent,
+ ),
+ "ttp:cellResolution": getCellResolutionComputedValue(attributes["ttp:cellResolution"]),
+ "tts:extent": extent,
+ } satisfies DocumentAttributes);
+}
+
+/**
+ * Having pixelAspectRatio without an extents is a standard
+ * deprecated behavior.
+ *
+ * @see https://www.w3.org/TR/2018/REC-ttml2-20181108/#parameter-attribute-pixelAspectRatio
+ *
+ * Any undefined result of the function here, brings us
+ * to a different Aspect Ration calculation procedure.
+ *
+ * @param values
+ * @param extent
+ * @returns
+ */
+
+function getPixelAspectRatio(values: number[], extent?: number[]): [number, number] | undefined {
+ if (!values || values.length < 2 || !extent) {
+ return undefined;
+ }
+
+ const [numerator, denominator] = values;
+
+ if (!numerator || !denominator) {
+ return undefined;
+ }
+
+ return [numerator, denominator];
+}
+
+function getDropModeResolvedValue(dropMode: string): DocumentAttributes["ttp:dropMode"] {
+ if (!dropMode) {
+ return "nonDrop";
+ }
+
+ const dropModes: ReadonlyArray = [
+ "dropNTSC",
+ "dropPAL",
+ "nonDrop",
+ ];
+
+ return dropModes.find((e) => e === dropMode) ?? "nonDrop";
+}
+
+function getFrameRateResolvedValue(
+ frameRate: string,
+): DocumentAttributes["ttp:frameRate"] | undefined {
+ const parsed = parseFloat(frameRate);
+
+ if (Number.isNaN(parsed) || parsed <= 0) {
+ return undefined;
+ }
+
+ return parsed;
+}
+
+function getFrameRateMultiplerResolvedValue(
+ frameRateMultiplier: string | undefined,
+): DocumentAttributes["ttp:frameRateMultiplier"] {
+ if (!frameRateMultiplier) {
+ return 1;
+ }
+
+ const parsed = asNumbers(getSplittedLinearWhitespaceValues(frameRateMultiplier));
+
+ if (parsed.length < 2) {
+ return 1;
+ }
+
+ const [numerator, denominator] = parsed;
+
+ if (Number.isNaN(numerator)) {
+ return 1;
+ }
+
+ if (Number.isNaN(denominator)) {
+ /** Like `parsed[0] / 1` **/
+ return numerator;
+ }
+
+ return numerator / denominator;
+}
+
+function getTickRateResolvedValue(
+ tickRate: string,
+ frameRate: number,
+ subFrameRate: number,
+): DocumentAttributes["ttp:tickRate"] {
+ if (!tickRate) {
+ return (frameRate ?? 0) * (subFrameRate ?? 1) || 1;
+ }
+
+ return parseFloat(tickRate);
+}
+
+function getTimeBaseResolvedValue(timeBase: string): DocumentAttributes["ttp:timeBase"] {
+ const dropModes: ReadonlyArray = ["media", "clock", "smpte"];
+
+ return dropModes.find((e) => e === timeBase) ?? "media";
+}
+
+/**
+ * It is not assumed that the presentation of text or
+ * the alignment of individual glyph areas is coordinated
+ * with this grid.
+ *
+ * Such alignment is possible, but requires the use of a
+ * monospaced font and a font size whose EM square exactly
+ * matches the cell size.
+ *
+ * @see https://www.w3.org/TR/2018/REC-ttml2-20181108/#parameter-attribute-cellResolution
+ * @param resolutionString
+ * @returns
+ */
+
+function getCellResolutionComputedValue(
+ resolutionString: string,
+): DocumentAttributes["ttp:cellResolution"] {
+ const DEFAULTS = [32, 15];
+
+ if (!resolutionString?.length) {
+ /**
+ * If not specified, the number of columns and rows must be considered
+ * to be 32 and 15, respectively.
+ *
+ * The choice of values 32 and 15 are based on this being the maximum
+ * number of columns and rows defined by [CTA-608-E].
+ *
+ * @see https://www.w3.org/TR/2018/REC-ttml2-20181108/#cta608e
+ */
+ return DEFAULTS;
+ }
+
+ let splittedValues = preventZeros(
+ asNumbers(getSplittedLinearWhitespaceValues(resolutionString)),
+ DEFAULTS,
+ );
+
+ if (splittedValues.length === 1) {
+ splittedValues = [splittedValues[0], splittedValues[0]];
+ }
+
+ return splittedValues;
+}
+
+function getMarkerModeResolvedValue(markerMode: string): DocumentAttributes["ttp:markerMode"] {
+ if (markerMode === "continuous" || markerMode === "discontinuous") {
+ return markerMode;
+ }
+
+ return "discontinuous";
+}
diff --git a/packages/ttml-adapter/src/Parser/Scope/RegionContainerContext.ts b/packages/ttml-adapter/src/Parser/Scope/RegionContainerContext.ts
new file mode 100644
index 0000000..e7ff23c
--- /dev/null
+++ b/packages/ttml-adapter/src/Parser/Scope/RegionContainerContext.ts
@@ -0,0 +1,96 @@
+import { Region } from "@sub37/server";
+import { NodeWithRelationship } from "../Tags/NodeTree";
+import type { Token } from "../Token";
+import { createRegionParser } from "../parseRegion.js";
+import type { TTMLRegion } from "../parseRegion.js";
+import type { Context, ContextFactory, Scope } from "./Scope";
+import { onAttachedSymbol, onMergeSymbol } from "./Scope.js";
+import type { TTMLStyle } from "../parseStyle";
+
+const regionContextSymbol = Symbol("region");
+const regionParserGetterSymbol = Symbol("region.parser");
+
+type RegionParser = ReturnType;
+
+export interface RegionContainerContextState {
+ attributes: Record;
+ children: NodeWithRelationship[];
+}
+
+interface RegionContainerContext
+ extends Context {
+ regions: Region[];
+ getRegionById(id: string | undefined): TTMLRegion | undefined;
+ getStylesByRegionId(id: string | undefined): TTMLStyle[];
+ [regionParserGetterSymbol]: RegionParser;
+}
+
+declare module "./Scope" {
+ interface ContextDictionary {
+ [regionContextSymbol]: RegionContainerContext;
+ }
+}
+
+export function createRegionContainerContext(
+ contextState: RegionContainerContextState[],
+): ContextFactory {
+ return function (scope: Scope) {
+ if (!contextState.length) {
+ return null;
+ }
+
+ const regionParser: RegionParser = createRegionParser(scope);
+
+ return {
+ parent: undefined,
+ identifier: regionContextSymbol,
+ get args() {
+ return contextState;
+ },
+ [onAttachedSymbol](): void {
+ for (const { attributes, children } of contextState) {
+ regionParser.process(attributes, children);
+ }
+ },
+ [onMergeSymbol](context: RegionContainerContext): void {
+ const { args } = context;
+
+ for (const { attributes, children } of args) {
+ regionParser.process(attributes, children);
+ }
+ },
+ get [regionParserGetterSymbol]() {
+ return regionParser;
+ },
+ getRegionById(id: string | undefined): TTMLRegion | undefined {
+ if (!id?.length) {
+ return undefined;
+ }
+
+ const regions = this.regions as TTMLRegion[];
+ return regions.find((region) => region.id === id);
+ },
+ getStylesByRegionId(id: string): TTMLStyle[] {
+ if (!id?.length) {
+ throw new Error("Cannot retrieve styles for a region with an unknown name.");
+ }
+
+ /** Pre-heating regions processing - if regions are not processed, we might get screwed */
+ if (!this.regions.some((r) => r.id === id)) {
+ return [];
+ }
+
+ return regionParser.get(id).styles;
+ },
+ get regions(): Region[] {
+ const parentRegions: Region[] = this.parent?.regions ?? [];
+
+ return parentRegions.concat(Object.values(regionParser.getAll()));
+ },
+ };
+ };
+}
+
+export function readScopeRegionContext(scope: Scope): RegionContainerContext | undefined {
+ return scope.getContextByIdentifier(regionContextSymbol);
+}
diff --git a/packages/ttml-adapter/src/Parser/Scope/Scope.ts b/packages/ttml-adapter/src/Parser/Scope/Scope.ts
new file mode 100644
index 0000000..d40ffc7
--- /dev/null
+++ b/packages/ttml-adapter/src/Parser/Scope/Scope.ts
@@ -0,0 +1,144 @@
+// Made to be extended by the contexts
+export interface ContextDictionary {
+ [K: symbol]: Context