XFA - Improve text layout

- support paragraph margins, line height, letter spacing, ...
  - compute missing dimensions from fields based almost on the dimensions of caption contents.
This commit is contained in:
Calixte Denizet 2021-07-02 17:53:27 +02:00
parent d80651e572
commit f7d3b22480
5 changed files with 359 additions and 99 deletions

View File

@ -14,11 +14,15 @@
*/
import {
$content,
$extra,
$getParent,
$getSubformParent,
$getTemplateRoot,
$globalData,
$nodeName,
$pushGlyphs,
$text,
$toStyle,
XFAObject,
} from "./xfa_object.js";
@ -191,8 +195,8 @@ function setMinMaxDimensions(node, style) {
}
}
function layoutText(text, xfaFont, fontFinder, width) {
const measure = new TextMeasure(xfaFont, fontFinder);
function layoutText(text, xfaFont, margin, lineHeight, fontFinder, width) {
const measure = new TextMeasure(xfaFont, margin, lineHeight, fontFinder);
if (typeof text === "string") {
measure.addString(text);
} else {
@ -202,6 +206,86 @@ function layoutText(text, xfaFont, fontFinder, width) {
return measure.compute(width);
}
function layoutNode(node, availableSpace) {
let height = null;
let width = null;
if ((!node.w || !node.h) && node.value) {
let marginH = 0;
let marginV = 0;
if (node.margin) {
marginH = node.margin.leftInset + node.margin.rightInset;
marginV = node.margin.topInset + node.margin.bottomInset;
}
let lineHeight = null;
let margin = null;
if (node.para) {
margin = Object.create(null);
lineHeight = node.para.lineHeight === "" ? null : node.para.lineHeight;
margin.top = node.para.spaceAbove === "" ? 0 : node.para.spaceAbove;
margin.bottom = node.para.spaceBelow === "" ? 0 : node.para.spaceBelow;
margin.left = node.para.marginLeft === "" ? 0 : node.para.marginLeft;
margin.right = node.para.marginRight === "" ? 0 : node.para.marginRight;
}
let font = node.font;
if (!font) {
const root = node[$getTemplateRoot]();
let parent = node[$getParent]();
while (parent !== root) {
if (parent.font) {
font = parent.font;
break;
}
parent = parent[$getParent]();
}
}
const maxWidth = !node.w ? availableSpace.width : node.w;
const fontFinder = node[$globalData].fontFinder;
if (
node.value.exData &&
node.value.exData[$content] &&
node.value.exData.contentType === "text/html"
) {
const res = layoutText(
node.value.exData[$content],
font,
margin,
lineHeight,
fontFinder,
maxWidth
);
width = res.width;
height = res.height;
} else {
const text = node.value[$text]();
if (text) {
const res = layoutText(
text,
font,
margin,
lineHeight,
fontFinder,
maxWidth
);
width = res.width;
height = res.height;
}
}
if (width !== null && !node.w) {
width += marginH;
}
if (height !== null && !node.h) {
height += marginV;
}
}
return [width, height];
}
function computeBbox(node, html, availableSpace) {
let bbox;
if (node.w !== "" && node.h !== "") {
@ -501,7 +585,7 @@ export {
fixTextIndent,
isPrintOnly,
layoutClass,
layoutText,
layoutNode,
measureToString,
setAccess,
setFontFamily,

View File

@ -74,7 +74,7 @@ import {
fixTextIndent,
isPrintOnly,
layoutClass,
layoutText,
layoutNode,
measureToString,
setAccess,
setFontFamily,
@ -911,6 +911,26 @@ class Caption extends XFAObject {
_setValue(this, value);
}
[$getExtra](availableSpace) {
if (!this[$extra]) {
let { width, height } = availableSpace;
switch (this.placement) {
case "left":
case "right":
case "inline":
width = this.reserve <= 0 ? width : this.reserve;
break;
case "top":
case "bottom":
height = this.reserve <= 0 ? height : this.reserve;
break;
}
this[$extra] = layoutNode(this, { width, height });
}
return this[$extra];
}
[$toHTML](availableSpace) {
// TODO: incomplete.
if (!this.value) {
@ -921,6 +941,23 @@ class Caption extends XFAObject {
if (!value) {
return HTMLResult.EMPTY;
}
const savedReserve = this.reserve;
if (this.reserve <= 0) {
const [w, h] = this[$getExtra](availableSpace);
switch (this.placement) {
case "left":
case "right":
case "inline":
this.reserve = w;
break;
case "top":
case "bottom":
this.reserve = h;
break;
}
}
const children = [];
if (typeof value === "string") {
children.push({
@ -937,20 +974,18 @@ class Caption extends XFAObject {
case "right":
if (this.reserve > 0) {
style.width = measureToString(this.reserve);
} else {
style.minWidth = measureToString(this.reserve);
}
break;
case "top":
case "bottom":
if (this.reserve > 0) {
style.height = measureToString(this.reserve);
} else {
style.minHeight = measureToString(this.reserve);
}
break;
}
this.reserve = savedReserve;
return HTMLResult.success({
name: "div",
attributes: {
@ -1569,63 +1604,22 @@ class Draw extends XFAObject {
fixDimensions(this);
if ((this.w === "" || this.h === "") && this.value) {
let marginH = 0;
let marginV = 0;
if (this.margin) {
marginH = this.margin.leftInset + this.margin.rightInset;
marginV = this.margin.topInset + this.margin.bottomInset;
}
const maxWidth = this.w === "" ? availableSpace.width : this.w;
const fontFinder = this[$globalData].fontFinder;
let font = this.font;
if (!font) {
let parent = this[$getParent]();
while (!(parent instanceof Template)) {
if (parent.font) {
font = parent.font;
break;
}
parent = parent[$getParent]();
}
}
let height = null;
let width = null;
if (
this.value.exData &&
this.value.exData[$content] &&
this.value.exData.contentType === "text/html"
) {
const res = layoutText(
this.value.exData[$content],
font,
fontFinder,
maxWidth
);
width = res.width;
height = res.height;
} else {
const text = this.value[$text]();
if (text) {
const res = layoutText(text, font, fontFinder, maxWidth);
width = res.width;
height = res.height;
}
}
if (width !== null && this.w === "") {
this.w = width + marginH;
}
if (height !== null && this.h === "") {
this.h = height + marginV;
}
// If at least one dimension is missing and we've a text
// then we can guess it in laying out the text.
const savedW = this.w;
const savedH = this.h;
const [w, h] = layoutNode(this, availableSpace);
if (w && this.w === "") {
this.w = w;
}
if (h && this.h === "") {
this.h = h;
}
setFirstUnsplittable(this);
if (!checkDimensions(this, availableSpace)) {
this.w = savedW;
this.h = savedH;
return HTMLResult.FAILURE;
}
unsetFirstUnsplittable(this);
@ -1673,6 +1667,8 @@ class Draw extends XFAObject {
const value = this.value ? this.value[$toHTML](availableSpace).html : null;
if (value === null) {
this.w = savedW;
this.h = savedH;
return HTMLResult.success(createWrapper(this, html), bbox);
}
@ -1714,6 +1710,9 @@ class Draw extends XFAObject {
}
}
this.w = savedW;
this.h = savedH;
return HTMLResult.success(createWrapper(this, html), bbox);
}
}
@ -2460,10 +2459,66 @@ class Field extends XFAObject {
return HTMLResult.EMPTY;
}
if (this.caption) {
// Maybe we already tried to layout this field with
// another availableSpace, so to avoid to use the cached
// value just delete it.
delete this.caption[$extra];
}
const caption = this.caption
? this.caption[$toHTML](availableSpace).html
: null;
const savedW = this.w;
const savedH = this.h;
if (this.w === "" || this.h === "") {
let marginH = 0;
let marginV = 0;
if (this.margin) {
marginH = this.margin.leftInset + this.margin.rightInset;
marginV = this.margin.topInset + this.margin.bottomInset;
}
let width = null;
let height = null;
if (this.caption) {
[width, height] = this.caption[$getExtra](availableSpace);
if (this.ui instanceof CheckButton) {
switch (this.caption.placement) {
case "left":
case "right":
case "inline":
width += this.ui.size;
break;
case "top":
case "bottom":
height += this.ui.size;
break;
}
}
}
if (width && this.w === "") {
this.w = Math.min(
this.maxW <= 0 ? Infinity : this.maxW,
Math.max(this.minW, width + marginH)
);
}
if (height && this.h === "") {
this.h = Math.min(
this.maxH <= 0 ? Infinity : this.maxH,
Math.max(this.minH, height + marginV)
);
}
}
fixDimensions(this);
setFirstUnsplittable(this);
if (!checkDimensions(this, availableSpace)) {
this.w = savedW;
this.h = savedH;
return HTMLResult.FAILURE;
}
unsetFirstUnsplittable(this);
@ -2559,12 +2614,14 @@ class Field extends XFAObject {
}
}
const caption = this.caption ? this.caption[$toHTML]().html : null;
if (!caption) {
if (ui.attributes.class) {
// Even if no caption this class will help to center the ui.
ui.attributes.class.push("xfaLeft");
}
this.w = savedW;
this.h = savedH;
return HTMLResult.success(createWrapper(this, html), bbox);
}
@ -2605,6 +2662,8 @@ class Field extends XFAObject {
break;
}
this.w = savedW;
this.h = savedH;
return HTMLResult.success(createWrapper(this, html), bbox);
}
}

View File

@ -15,17 +15,30 @@
import { selectFont } from "./fonts.js";
const WIDTH_FACTOR = 1.2;
const HEIGHT_FACTOR = 1.2;
const WIDTH_FACTOR = 1.05;
class FontInfo {
constructor(xfaFont, fontFinder) {
constructor(xfaFont, margin, lineHeight, fontFinder) {
this.lineHeight = lineHeight;
this.paraMargin = margin || {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
if (!xfaFont) {
[this.pdfFont, this.xfaFont] = this.defaultFont(fontFinder);
return;
}
this.xfaFont = xfaFont;
this.xfaFont = {
typeface: xfaFont.typeface,
posture: xfaFont.posture,
weight: xfaFont.weight,
size: xfaFont.size,
letterSpacing: xfaFont.letterSpacing,
};
const typeface = fontFinder.find(xfaFont.typeface);
if (!typeface) {
[this.pdfFont, this.xfaFont] = this.defaultFont(fontFinder);
@ -54,6 +67,7 @@ class FontInfo {
posture: "normal",
weight: "normal",
size: 10,
letterSpacing: 0,
};
return [pdfFont, xfaFont];
}
@ -63,29 +77,60 @@ class FontInfo {
posture: "normal",
weight: "normal",
size: 10,
letterSpacing: 0,
};
return [null, xfaFont];
}
}
class FontSelector {
constructor(defaultXfaFont, fontFinder) {
constructor(
defaultXfaFont,
defaultParaMargin,
defaultLineHeight,
fontFinder
) {
this.fontFinder = fontFinder;
this.stack = [new FontInfo(defaultXfaFont, fontFinder)];
this.stack = [
new FontInfo(
defaultXfaFont,
defaultParaMargin,
defaultLineHeight,
fontFinder
),
];
}
pushFont(xfaFont) {
pushData(xfaFont, margin, lineHeight) {
const lastFont = this.stack[this.stack.length - 1];
for (const name of ["typeface", "posture", "weight", "size"]) {
for (const name of [
"typeface",
"posture",
"weight",
"size",
"letterSpacing",
]) {
if (!xfaFont[name]) {
xfaFont[name] = lastFont.xfaFont[name];
}
}
const fontInfo = new FontInfo(xfaFont, this.fontFinder);
for (const name of ["top", "bottom", "left", "right"]) {
if (isNaN(margin[name])) {
margin[name] = lastFont.paraMargin[name];
}
}
const fontInfo = new FontInfo(
xfaFont,
margin,
lineHeight || lastFont.lineHeight,
this.fontFinder
);
if (!fontInfo.pdfFont) {
fontInfo.pdfFont = lastFont.pdfFont;
}
this.stack.push(fontInfo);
}
@ -102,19 +147,30 @@ class FontSelector {
* Compute a text area dimensions based on font metrics.
*/
class TextMeasure {
constructor(defaultXfaFont, fonts) {
constructor(defaultXfaFont, defaultParaMargin, defaultLineHeight, fonts) {
this.glyphs = [];
this.fontSelector = new FontSelector(defaultXfaFont, fonts);
this.fontSelector = new FontSelector(
defaultXfaFont,
defaultParaMargin,
defaultLineHeight,
fonts
);
this.extraHeight = 0;
}
pushFont(xfaFont) {
return this.fontSelector.pushFont(xfaFont);
pushData(xfaFont, margin, lineHeight) {
this.fontSelector.pushData(xfaFont, margin, lineHeight);
}
popFont(xfaFont) {
return this.fontSelector.popFont();
}
addPara() {
const lastFont = this.fontSelector.topFont();
this.extraHeight += lastFont.paraMargin.top + lastFont.paraMargin.bottom;
}
addString(str) {
if (!str) {
return;
@ -123,8 +179,11 @@ class TextMeasure {
const lastFont = this.fontSelector.topFont();
const fontSize = lastFont.xfaFont.size;
if (lastFont.pdfFont) {
const letterSpacing = lastFont.xfaFont.letterSpacing;
const pdfFont = lastFont.pdfFont;
const lineHeight = Math.round(Math.max(1, pdfFont.lineHeight) * fontSize);
const lineHeight =
lastFont.lineHeight ||
Math.round(Math.max(1, pdfFont.lineHeight) * fontSize);
const scale = fontSize / 1000;
for (const line of str.split(/[\u2029\n]/)) {
@ -133,7 +192,7 @@ class TextMeasure {
for (const glyph of glyphs) {
this.glyphs.push([
glyph.width * scale,
glyph.width * scale + letterSpacing,
lineHeight,
glyph.unicode === " ",
false,
@ -218,9 +277,9 @@ class TextMeasure {
}
width = Math.max(width, currentLineWidth);
height += currentLineHeight;
height += currentLineHeight + this.extraHeight;
return { width: WIDTH_FACTOR * width, height: HEIGHT_FACTOR * height };
return { width: WIDTH_FACTOR * width, height };
}
}

View File

@ -193,25 +193,81 @@ class XhtmlObject extends XmlObject {
}
}
[$pushGlyphs](measure) {
[$pushGlyphs](measure, mustPop = true) {
const xfaFont = Object.create(null);
const margin = {
top: NaN,
bottom: NaN,
left: NaN,
right: NaN,
};
let lineHeight = null;
for (const [key, value] of this.style
.split(";")
.map(s => s.split(":", 2))) {
if (!key.startsWith("font-")) {
continue;
}
if (key === "font-family") {
xfaFont.typeface = stripQuotes(value);
} else if (key === "font-size") {
xfaFont.size = getMeasurement(value);
} else if (key === "font-weight") {
xfaFont.weight = value;
} else if (key === "font-style") {
xfaFont.posture = value;
switch (key) {
case "font-family":
xfaFont.typeface = stripQuotes(value);
break;
case "font-size":
xfaFont.size = getMeasurement(value);
break;
case "font-weight":
xfaFont.weight = value;
break;
case "font-style":
xfaFont.posture = value;
break;
case "letter-spacing":
xfaFont.letterSpacing = getMeasurement(value);
break;
case "margin":
const values = value.split(/ \t/).map(x => getMeasurement(x));
switch (values.length) {
case 1:
margin.top =
margin.bottom =
margin.left =
margin.right =
values[0];
break;
case 2:
margin.top = margin.bottom = values[0];
margin.left = margin.right = values[1];
break;
case 3:
margin.top = values[0];
margin.bottom = values[2];
margin.left = margin.right = values[1];
break;
case 4:
margin.top = values[0];
margin.left = values[1];
margin.bottom = values[2];
margin.right = values[3];
break;
}
break;
case "margin-top":
margin.top = getMeasurement(value);
break;
case "margin-bottom":
margin.bottom = getMeasurement(value);
break;
case "margin-left":
margin.left = getMeasurement(value);
break;
case "margin-right":
margin.right = getMeasurement(value);
break;
case "line-height":
lineHeight = getMeasurement(value);
break;
}
}
measure.pushFont(xfaFont);
measure.pushData(xfaFont, margin, lineHeight);
if (this[$content]) {
measure.addString(this[$content]);
} else {
@ -223,7 +279,10 @@ class XhtmlObject extends XmlObject {
child[$pushGlyphs](measure);
}
}
measure.popFont();
if (mustPop) {
measure.popFont();
}
}
[$toHTML](availableSpace) {
@ -377,8 +436,10 @@ class P extends XhtmlObject {
}
[$pushGlyphs](measure) {
super[$pushGlyphs](measure);
super[$pushGlyphs](measure, /* mustPop = */ false);
measure.addString("\n");
measure.addPara();
measure.popFont();
}
[$text]() {

View File

@ -190,6 +190,8 @@
.xfaRich {
white-space: pre-wrap;
width: auto;
height: auto;
}
.xfaImage {
@ -199,11 +201,6 @@
height: 100%;
}
.xfaRich {
width: 100%;
height: auto;
}
.xfaLrTb,
.xfaRlTb,
.xfaTb {