diff --git a/README.md b/README.md index 600d6906..5456e05f 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,13 @@ pipenv install pipenv run python3 src/test/script/compare_renders.py ``` +[src/test/script/gen-compare-site.mjs](src/test/script/gen-compare-site.mjs) generates, under `build/compare`, a static website that compares imscJS renders with the reference PNGs of the [W3C IMSC test suite](https://github.com/w3c/imsc-tests). + +```sh +npm run build +npm run gen-compare-site +``` + ### Unit tests Unit tests run using Node's built-in [test runner](https://nodejs.org/api/test.html) and are located at [src/test/js](src/test/js). They can be run with: diff --git a/package.json b/package.json index 30a6a4d5..0a572d1f 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "gen-imsc1_1": "node src/test/script/gen-render-package.mjs imsc-tests/imsc1_1 build/renders-imsc1_1.zip", "gen-imsc1_3": "node src/test/script/gen-render-package.mjs imsc-tests/imsc1_3 build/renders-imsc1_3.zip", "gen-reference-files": "node src/test/script/gen-reference-files.mjs", + "gen-compare-site": "node src/test/script/gen-compare-site.mjs", "install:firefox": "puppeteer browsers install firefox", "lint": "eslint .", "fix": "eslint --fix src", diff --git a/src/main/js/html.js b/src/main/js/html.js index 5385fbb2..752ea2e7 100755 --- a/src/main/js/html.js +++ b/src/main/js/html.js @@ -24,7 +24,7 @@ * POSSIBILITY OF SUCH DAMAGE. */ -import { reportError } from "./error.js"; +import { reportError, reportWarning } from "./error.js"; import { byName } from "./styles.js"; /** @@ -211,15 +211,6 @@ function processElement(context, dom_parent, isd_element, isd_parent) { e = document.createElement("rt"); - } else if (isd_element.styleAttrs[byName.ruby.qname] === "baseContainer") { - - e = document.createElement("span"); // rbc element is deprecated in HTML - - } else if (isd_element.styleAttrs[byName.ruby.qname] === "textContainer") { - - e = document.createElement("span"); // rtc element is deprecated in HTML - e.style.display = "ruby-text-container"; - } else if (isd_element.styleAttrs[byName.ruby.qname] === "delimiter") { /* ignore rp */ @@ -397,10 +388,11 @@ function processElement(context, dom_parent, isd_element, isd_parent) { } - if (byName.textCombine.qname in isd_element.styleAttrs && - isd_element.styleAttrs[byName.textCombine.qname] === "all") { + if ((byName.textCombine.qname in isd_element.styleAttrs && + isd_element.styleAttrs[byName.textCombine.qname] === "all") || + e.closest("rt") !== null) { - /* ignore tate-chu-yoku since line break cannot happen within */ + /* ignore tate-chu-yoku and ruby text since line break cannot happen within */ e.textContent = isd_element.text; e._isd_element = isd_element; @@ -455,9 +447,19 @@ function processElement(context, dom_parent, isd_element, isd_parent) { if ("contents" in isd_element) { - for (let k = 0; k < isd_element.contents.length; k++) { + if (isd_element.kind === "span" && + isd_element.styleAttrs[byName.ruby.qname] === "container" && + isd_element.contents.some((c) => c.styleAttrs[byName.ruby.qname] === "baseContainer")) { + + processRubyContainer(context, e, isd_element); + + } else { + + for (let k = 0; k < isd_element.contents.length; k++) { + + processElement(context, proc_e, isd_element.contents[k], isd_element); - processElement(context, proc_e, isd_element.contents[k], isd_element); + } } @@ -583,6 +585,104 @@ function processElement(context, dom_parent, isd_element, isd_parent) { } } +/* + * Maps a TTML ruby container that contains a ruby base container to N + * elements, one for each ruby base of the base container, since and + * are deprecated. + * + * The ith element contains: + * - the ith ruby base, or, if there are two ruby text containers, a nested + * element that contains the ith ruby base and the ith ruby text of the + * second text container; followed by + * - the ith ruby text of the first text container. + * + * If a text container has more ruby texts than the base container has ruby + * bases, the extra ruby texts are kept and annotate an empty base. + */ +function processRubyContainer(context, ruby, isd_ruby) { + + let bases = []; + + const textContainers = []; + + for (const c of isd_ruby.contents) { + + if (c.styleAttrs[byName.ruby.qname] === "baseContainer") { + + bases = c.contents.filter((b) => b.styleAttrs[byName.ruby.qname] === "base"); + + } else if (c.styleAttrs[byName.ruby.qname] === "textContainer") { + + textContainers.push(c.contents.filter((t) => t.styleAttrs[byName.ruby.qname] === "text")); + + } + + } + + /* number of elements, i.e. the greater of the number of ruby bases and the number of ruby text in the first two text containers */ + + let ruby_count = bases.length; + + for (let k = 0; k < textContainers.length && k < 2; k++) { + + ruby_count = Math.max(ruby_count, textContainers[k].length); + + } + + if (ruby_count > bases.length) { + + reportWarning(context.errorHandler, "Ruby text container has more ruby text than the ruby base container has ruby bases."); + + } + + const template = ruby.cloneNode(false); + + let outer = ruby; + + for (let i = 0; i < ruby_count; i++) { + + if (i > 0) { + + const next = template.cloneNode(false); + + outer.parentElement.insertBefore(next, outer.nextSibling); + + outer = next; + + } + + let base_parent = outer; + + if (textContainers.length > 1) { + + base_parent = template.cloneNode(false); + + outer.appendChild(base_parent); + + } + + if (i < bases.length) { + + processElement(context, base_parent, bases[i], isd_ruby); + + } + + if (textContainers.length > 1 && i < textContainers[1].length) { + + processElement(context, base_parent, textContainers[1][i], isd_ruby); + + } + + if (textContainers.length > 0 && i < textContainers[0].length) { + + processElement(context, outer, textContainers[0][i], isd_ruby); + + } + + } + +} + function mergeSpans(lineList, context) { for (let i = 0; i < lineList.length; i++) { @@ -886,88 +986,76 @@ function applyRubyReserve(lineList, context) { for (let i = 0; i < lineList.length; i++) { - const ruby = document.createElement("ruby"); - - const rb = document.createElement("span"); // rb element is deprecated in HTML - rb.textContent = "\u200B"; - - ruby.appendChild(rb); + const fs = context.rubyReserve[1].toUsedLength(context.w, context.h) + "px"; - let rt1; - let rt2; + /* We cannot use the deprecated to reserve space, so instead we use nested rubies */ - const fs = context.rubyReserve[1].toUsedLength(context.w, context.h) + "px"; + const reserved_pos = []; if (context.rubyReserve[0] === "both" || (context.rubyReserve[0] === "outside" && lineList.length == 1)) { - rt1 = document.createElement("span"); // rtc element is deprecated in HTML - rt1.style.display = "ruby-text-container"; - rt1.style[RUBYPOSITION_PROP] = RUBYPOSITION_ISWK ? "after" : "under"; - rt1.textContent = "\u200B"; - rt1.style.fontSize = fs; + reserved_pos.push(RUBYPOSITION_ISWK ? "after" : "under"); + reserved_pos.push(RUBYPOSITION_ISWK ? "before" : "over"); - rt2 = document.createElement("span"); // rtc element is deprecated in HTML - rt2.style.display = "ruby-text-container"; - rt2.style[RUBYPOSITION_PROP] = RUBYPOSITION_ISWK ? "before" : "over"; - rt2.textContent = "\u200B"; - rt2.style.fontSize = fs; + } else if (context.rubyReserve[0] === "after" || (context.rubyReserve[0] === "outside" && i > 0)) { - ruby.appendChild(rt1); - ruby.appendChild(rt2); + reserved_pos.push(RUBYPOSITION_ISWK ? "after" : ((context.bpd === "tb" || context.bpd === "rl") ? "under" : "over")); } else { - rt1 = document.createElement("span"); // rtc element is deprecated in HTML - rt1.style.display = "ruby-text-container"; - rt1.textContent = "\u200B"; - rt1.style.fontSize = fs; + reserved_pos.push(RUBYPOSITION_ISWK ? "before" : ((context.bpd === "tb" || context.bpd === "rl") ? "over" : "under")); - let pos; + } - if (context.rubyReserve[0] === "after" || (context.rubyReserve[0] === "outside" && i > 0)) { + /* add in front of the first ruby element of the line, if it exists */ - pos = RUBYPOSITION_ISWK ? "after" : ((context.bpd === "tb" || context.bpd === "rl") ? "under" : "over"); + let ruby_sibling = null; - } else { - - pos = RUBYPOSITION_ISWK ? "before" : ((context.bpd === "tb" || context.bpd === "rl") ? "over" : "under"); + for (let j = 0; j < lineList[i].rbc.length; j++) { - } + if (lineList[i].rbc[j].localName === "ruby") { - rt1.style[RUBYPOSITION_PROP] = pos; + ruby_sibling = lineList[i].rbc[j]; - ruby.appendChild(rt1); + break; + } } - /* add in front of the first ruby element of the line, if it exists */ + let base = document.createElement("span"); // rb element is deprecated in HTML + base.textContent = "\u200B"; - let sib = null; + for (const pos of reserved_pos) { - for (let j = 0; j < lineList[i].rbc.length; j++) { + const ruby = document.createElement("ruby"); - if (lineList[i].rbc[j].localName === "ruby") { + ruby.appendChild(base); - sib = lineList[i].rbc[j]; + const rt = document.createElement("rt"); + rt.style[RUBYPOSITION_PROP] = pos; + rt.textContent = "\u200B"; + rt.style.fontSize = fs; - /* copy specified style properties from the sibling ruby container */ + ruby.appendChild(rt); - for (let k = 0; k < sib.style.length; k++) { + /* copy specified style properties from the sibling ruby container */ - ruby.style.setProperty(sib.style.item(k), sib.style.getPropertyValue(sib.style.item(k))); + if (ruby_sibling !== null) { + for (let k = 0; k < ruby_sibling.style.length; k++) { + ruby.style.setProperty(ruby_sibling.style.item(k), ruby_sibling.style.getPropertyValue(ruby_sibling.style.item(k))); } - break; } - } + ruby.style[RUBYPOSITION_PROP] = pos; // for Chrome - /* otherwise add before first span */ - - sib = sib || lineList[i].elements[0].node; + base = ruby; + } - sib.parentElement.insertBefore(ruby, sib); + /* add before first span if there is no ruby sibling */ + const actual_sibling = ruby_sibling || lineList[i].elements[0].node; + actual_sibling.parentElement.insertBefore(base, actual_sibling); } @@ -1113,7 +1201,7 @@ function rect2edges(rect, context) { function constructLineList(context, element, llist, bgcolor) { - if (element.localName === "rt" || element.style.display === "ruby-text-container") { + if (element.localName === "rt") { /* skip ruby annotations */ @@ -1205,9 +1293,9 @@ function constructLineList(context, element, llist, bgcolor) { constructLineList(context, child, llist, curbgcolor); - if (child.localName === "ruby" || child.style.display === "ruby-text-container") { + if (child.localName === "ruby") { - /* remember non-empty ruby and rtc elements so that tts:rubyPosition can be applied */ + /* remember non-empty ruby elements so that tts:rubyPosition can be applied */ if (llist.length > 0) { @@ -1640,7 +1728,7 @@ const STYLING_MAP_DEFS = [ } - /* apply position to the parent dom_element, i.e. ruby or rtc */ + /* apply position to the parent dom_element, i.e. ruby */ dom_element.parentElement.style[RUBYPOSITION_PROP] = pos; } diff --git a/src/test/script/gen-compare-site.mjs b/src/test/script/gen-compare-site.mjs new file mode 100644 index 00000000..0ac9f8db --- /dev/null +++ b/src/test/script/gen-compare-site.mjs @@ -0,0 +1,404 @@ +#!/usr/bin/env node +/* + * Copyright (c) Sandflow Consulting LLC + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Generates a static website that compares the HTML renders generated by + * imscJS for the imsc-tests test suites with the reference PNGs included in + * imsc-tests. + * + * imscJS renders the events of a test live, when the test is selected, in the + * browser viewing the site, so that layout-dependent values (e.g. fillLineGap + * paddings) are computed using that browser's fonts. The reference PNG is shown next to the render and can be + * overlaid on top of it, either blended in difference mode or with adjustable + * opacity. + * + * TTML documents are embedded in the pages, since browsers do not allow + * fetch() from file:// pages, and the site can therefore be opened directly + * from disk. + * + * Requires dist/imsc.debug.js (npm run build). + * + * Usage: + * node src/test/script/gen-compare-site.mjs [imsc-tests/imsc1 imsc-tests/imsc1_1 ...] [--out=build/compare] + * + * Open /index.html in a browser. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { RESOURCES_DIR, IMSC_BUNDLE_PATH } from "./render-harness.mjs"; + +const DEFAULT_REFFILES_ROOTS = ["imsc-tests/imsc1", "imsc-tests/imsc1_1", "imsc-tests/imsc1_2", "imsc-tests/imsc1_3"]; + +/* Must match the dimensions of the reference PNGs */ +const RENDER_WIDTH = 640; +const RENDER_HEIGHT = 360; + +function escapeHTML(s) { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/* JSON that can be safely embedded in a + + +
+

imscJS / ${escapeHTML(suiteName)}

+ + + + +↑/↓: previous/next test +
+
+ +
+
+ + + +`; +} + +function indexPage(suites) { + const items = suites + .map(({ name, testCount }) => + `
  • ${escapeHTML(name)} ` + + `${testCount} tests
  • `) + .join("\n"); + + return ` + + + + +imscJS renders + + + +

    imscJS renders vs. imsc-tests reference PNGs

    +

    Generated ${escapeHTML(new Date().toISOString())}

    +
      +${items} +
    + + +`; +} + +function generateSuite(reffilesRoot, outDir) { + const suiteName = path.basename(reffilesRoot); + const suiteDir = path.join(outDir, suiteName); + const srcDir = path.join(RESOURCES_DIR, reffilesRoot); + + const testList = JSON.parse(fs.readFileSync(path.join(srcDir, "tests.json"), "utf8")); + + fs.rmSync(suiteDir, { recursive: true, force: true }); + + /* images referenced by the TTML documents are resolved relative to them */ + fs.cpSync(path.join(srcDir, "ttml"), path.join(suiteDir, "ttml"), { recursive: true }); + fs.cpSync(path.join(srcDir, "png"), path.join(suiteDir, "ref"), { recursive: true }); + + const tests = []; + + for (const finfo of testList) { + const name = finfo.name || path.basename(finfo.path).split(".")[0]; + + const refDir = path.join(srcDir, "png", name); + const refEvents = fs.existsSync(refDir) + ? fs.readdirSync(refDir).filter((f) => f.endsWith(".png")).map((f) => f.slice(0, -4)) + : []; + + tests.push({ + name, + path: finfo.path, + params: finfo.params || {}, + ttml: fs.readFileSync(path.join(srcDir, "ttml", finfo.path), "utf8"), + ttmlDir: "ttml/" + path.posix.dirname(finfo.path) + "/", + refDir: "ref/" + name + "/", + refEvents, + }); + } + + writeFile(path.join(suiteDir, "index.html"), suitePage(suiteName, tests)); + + console.log(`Wrote ${suiteDir} (${tests.length} tests)`); + + return { name: suiteName, testCount: tests.length }; +} + +function main() { + if (!fs.existsSync(IMSC_BUNDLE_PATH)) { + throw new Error(`${IMSC_BUNDLE_PATH} does not exist.`); + } + + const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); + const reffilesRoots = args.length ? args : DEFAULT_REFFILES_ROOTS; + + const outArg = process.argv.find((a) => a.startsWith("--out=")); + const outDir = path.resolve(outArg ? outArg.split("=")[1] : "build/compare"); + + fs.mkdirSync(outDir, { recursive: true }); + fs.copyFileSync(IMSC_BUNDLE_PATH, path.join(outDir, "imsc.debug.js")); + if (fs.existsSync(IMSC_BUNDLE_PATH + ".map")) { + fs.copyFileSync(IMSC_BUNDLE_PATH + ".map", path.join(outDir, "imsc.debug.js.map")); + } + + const suites = reffilesRoots.map((reffilesRoot) => generateSuite(reffilesRoot, outDir)); + + writeFile(path.join(outDir, "index.html"), indexPage(suites)); + + console.log(`Open ${path.join(outDir, "index.html")}`); +} + +main();