diff --git a/src/nodes/text.ts b/src/nodes/text.ts index 13128874..4cd0c07e 100644 --- a/src/nodes/text.ts +++ b/src/nodes/text.ts @@ -10,21 +10,115 @@ import { replaceTabsBySpace, transformText, transformXmlSpace, - trimLeft, - trimRight + trimLeft } from '../utils/text' import { GraphicsNode } from './graphicsnode' import { Rect } from '../utils/geometry' import { Matrix } from 'jspdf' +import { SvgNode } from './svgnode' +import { parseAttributes } from '../applyparseattributes' + +interface TrimInfo { + prevText: string + prevContext: Context +} export class TextNode extends GraphicsNode { + private processTSpans( + textNode: SvgNode, + node: Element, + context: Context, + textChunks: { type: 'x' | 'y' | ''; chunk: TextChunk }[], + currentTextSegment: TextChunk, + trimInfo: TrimInfo + ): boolean { + const pdfFontSize = context.pdf.getFontSize() + const xmlSpace = context.attributeState.xmlSpace + let firstText = true, + initialSpace = false + + for (let i = 0; i < node.childNodes.length; i++) { + const childNode = node.childNodes[i] as Element + if (!childNode.textContent) { + continue + } + + const textContent = childNode.textContent + + if (childNode.nodeName === '#text') { + let trimmedText = removeNewlines(textContent) + trimmedText = replaceTabsBySpace(trimmedText) + + if (xmlSpace === 'default') { + trimmedText = consolidateSpaces(trimmedText) + // If first text in tspan and starts with a space + if (firstText && trimmedText.match(/^\s/)) { + initialSpace = true + } + // No longer the first text if we've found a letter + if (trimmedText.match(/[^\s]/)) { + firstText = false + } + // Consolidate spaces across different children + if (trimInfo.prevText.match(/\s$/)) { + trimmedText = trimLeft(trimmedText) + } + } + + const transformedText = transformText(node, trimmedText, context) + currentTextSegment.add(node, transformedText, context) + trimInfo.prevText = textContent + trimInfo.prevContext = context + } else if (nodeIs(childNode, 'title')) { + // ignore elements + } else if (nodeIs(childNode, 'tspan')) { + const tSpan = childNode + + const tSpanAbsX = tSpan.getAttribute('x') + if (tSpanAbsX !== null) { + const x = toPixels(tSpanAbsX, pdfFontSize) + + currentTextSegment = new TextChunk( + this, + getAttribute(tSpan, context.styleSheets, 'text-anchor') || + context.attributeState.textAnchor, + x, + 0 + ) + textChunks.push({ type: 'y', chunk: currentTextSegment }) + } + + const tSpanAbsY = tSpan.getAttribute('y') + if (tSpanAbsY !== null) { + const y = toPixels(tSpanAbsY, pdfFontSize) + + currentTextSegment = new TextChunk( + this, + getAttribute(tSpan, context.styleSheets, 'text-anchor') || + context.attributeState.textAnchor, + 0, + y + ) + textChunks.push({ type: 'x', chunk: currentTextSegment }) + } + + const childContext = context.clone() + parseAttributes(childContext, textNode, tSpan) + + this.processTSpans(textNode, tSpan, childContext, textChunks, currentTextSegment, trimInfo) + } + } + + return initialSpace + } + protected async renderCore(context: Context): Promise<void> { context.pdf.saveGraphicsState() let xOffset = 0 let charSpace = 0 - // If string starts with (\n\r | \t | ' ') then for charSpace calculations - // need to treat the string as if it contains one extra character + // If string starts with \s then for charSpace calculations + // need to treat it as if it contains one extra character let lengthAdjustment = 1 const pdfFontSize = context.pdf.getFontSize() @@ -40,8 +134,8 @@ export class TextNode extends GraphicsNode { // when there are no tspans draw the text directly const tSpanCount = this.element.childElementCount if (tSpanCount === 0) { - const originalText = this.element.textContent || '' - const trimmedText = transformXmlSpace(originalText, context.attributeState) + const textContent = this.element.textContent || '' + const trimmedText = transformXmlSpace(textContent, context.attributeState) const transformedText = transformText(this.element, trimmedText, context) xOffset = context.textMeasure.getTextOffset(transformedText, context.attributeState) @@ -50,7 +144,7 @@ export class TextNode extends GraphicsNode { transformedText, context.attributeState ) - if (context.attributeState.xmlSpace === 'default' && originalText.match(/^\s/)) { + if (context.attributeState.xmlSpace === 'default' && textContent.match(/^\s/)) { lengthAdjustment = 0 } charSpace = (textLength - defaultSize) / (transformedText.length - lengthAdjustment) || 0 @@ -68,11 +162,8 @@ export class TextNode extends GraphicsNode { } } else { // otherwise loop over tspans and position each relative to the previous one - // type sets how the chunk uses the position of the previous chunk to define its origin - // x/y means it uses the x/y position of the previous to set it's x/y origin repectively const textChunks: { type: 'x' | 'y' | ''; chunk: TextChunk }[] = [] - - let currentTextSegment = new TextChunk( + const currentTextSegment = new TextChunk( this, context.attributeState.textAnchor, textX + dx, @@ -80,121 +171,50 @@ export class TextNode extends GraphicsNode { ) textChunks.push({ type: '', chunk: currentTextSegment }) - for (let i = 0; i < this.element.childNodes.length; i++) { - const textNode = this.element.childNodes[i] as Element - if (!textNode.textContent) { - continue - } - - const originalText = textNode.textContent - - let xmlSpace = context.attributeState.xmlSpace - let textContent = originalText - - if (textNode.nodeName === '#text') { - } else if (nodeIs(textNode, 'title')) { - continue - } else if (nodeIs(textNode, 'tspan')) { - const tSpan = textNode - - if (tSpan.childElementCount > 0) { - // filter <title> elements... - textContent = '' - for (let j = 0; j < tSpan.childNodes.length; j++) { - if (tSpan.childNodes[j].nodeName === '#text') { - textContent += tSpan.childNodes[j].textContent - } - } - } - - const tSpanAbsX = tSpan.getAttribute('x') - if (tSpanAbsX !== null) { - const x = toPixels(tSpanAbsX, pdfFontSize) - - currentTextSegment = new TextChunk( - this, - getAttribute(tSpan, context.styleSheets, 'text-anchor') || - context.attributeState.textAnchor, - x, - 0 - ) - textChunks.push({ type: 'y', chunk: currentTextSegment }) - } - - const tSpanAbsY = tSpan.getAttribute('y') - if (tSpanAbsY !== null) { - const y = toPixels(tSpanAbsY, pdfFontSize) - - currentTextSegment = new TextChunk( - this, - getAttribute(tSpan, context.styleSheets, 'text-anchor') || - context.attributeState.textAnchor, - 0, - y - ) - textChunks.push({ type: 'x', chunk: currentTextSegment }) - } - - const tSpanXmlSpace = tSpan.getAttribute('xml:space') - if (tSpanXmlSpace) { - xmlSpace = tSpanXmlSpace - } - } - - let trimmedText = removeNewlines(textContent) - trimmedText = replaceTabsBySpace(trimmedText) + const initialSpace = this.processTSpans( + this, + this.element, + context, + textChunks, + currentTextSegment, + // Set prevText to ' ' so any spaces on left of <text> are trimmed + { prevText: ' ', prevContext: context } + ) - if (xmlSpace === 'default') { - if (i === 0) { - trimmedText = trimLeft(trimmedText) - if (originalText.match(/^\s/)) { - lengthAdjustment = 0 - } - } - if (i === this.element.childNodes.length - 1) { - trimmedText = trimRight(trimmedText) - } + lengthAdjustment = initialSpace ? 0 : 1 - trimmedText = consolidateSpaces(trimmedText) + // Right trim the chunks (if required) + let trimRight = true + for (let r = textChunks.length - 1; r >= 0; r--) { + if (trimRight) { + trimRight = textChunks[r].chunk.rightTrimText() } - - const transformedText = transformText(this.element, trimmedText, context) - currentTextSegment.add(textNode, transformedText) } - // These arrays are (from inside out) per text per TextChunk - const measures: { width: number; length: number }[][] = [] - let textWidths: number[][] | null = null - if (textLength > 0) { // Calculate the total 'default' width of this text element let totalDefaultWidth = 0 let totalLength = 0 textChunks.forEach(({ chunk }) => { - const chunkMeasures = chunk.measureTexts(context) - measures.push(chunkMeasures) - chunkMeasures.forEach(({ width, length }) => { + chunk.measureText(context) + chunk.textMeasures.forEach(({ width, length }) => { totalDefaultWidth += width totalLength += length }) }) charSpace = (textLength - totalDefaultWidth) / (totalLength - lengthAdjustment) - - textWidths = measures.map(chunkMeasures => - chunkMeasures.map(textMeasure => textMeasure.width + textMeasure.length * charSpace) - ) } // Put the textchunks textChunks.reduce( - (lastPositions, { type, chunk }, i) => { + (lastPositions, { type, chunk }) => { if (type === 'x') { chunk.setX(lastPositions[0]) } else if (type === 'y') { chunk.setY(lastPositions[1]) } - return chunk.put(context, charSpace, textWidths ? textWidths[i] : null) + return chunk.put(context, charSpace) }, [0, 0] ) diff --git a/src/textchunk.ts b/src/textchunk.ts index 8b001491..bd722254 100644 --- a/src/textchunk.ts +++ b/src/textchunk.ts @@ -1,13 +1,9 @@ -import { RGBColor } from './utils/rgbcolor' - import { Context } from './context/context' -import { getTextRenderingMode } from './utils/text' -import { getAttribute } from './utils/node' +import { getTextRenderingMode, trimRight } from './utils/text' import { mapAlignmentBaseline, toPixels } from './utils/misc' -import { applyAttributes, parseAttributes } from './applyparseattributes' +import { applyAttributes } from './applyparseattributes' import { TextNode } from './nodes/text' import { Point } from './utils/geometry' -import { ColorFill } from './fill/ColorFill' /** * @param {string} textAnchor @@ -19,17 +15,22 @@ export class TextChunk { private readonly textNode: TextNode private readonly texts: string[] private readonly textNodes: Element[] + private readonly contexts: Context[] private readonly textAnchor: string private originX: number private originY: number + readonly textMeasures: { width: number; length: number }[] + constructor(parent: TextNode, textAnchor: string, originX: number, originY: number) { this.textNode = parent this.texts = [] this.textNodes = [] + this.contexts = [] this.textAnchor = textAnchor this.originX = originX this.originY = originY + this.textMeasures = [] } setX(originX: number): void { @@ -40,91 +41,75 @@ export class TextChunk { this.originY = originY } - add(tSpan: Element, text: string): void { + add(tSpan: Element, text: string, context: Context): void { this.texts.push(text) this.textNodes.push(tSpan) + this.contexts.push(context) } - measureTexts(context: Context): { width: number; length: number }[] { - let i, textNode - - const measures: { width: number; length: number }[] = [] - - for (i = 0; i < this.textNodes.length; i++) { - textNode = this.textNodes[i] - - let textNodeContext - if (textNode.nodeName === '#text') { - textNodeContext = context - } else { - textNodeContext = context.clone() - parseAttributes(textNodeContext, this.textNode, textNode) + rightTrimText(): boolean { + for (let r = this.texts.length - 1; r >= 0; r--) { + if (this.contexts[r].attributeState.xmlSpace === 'default') { + this.texts[r] = trimRight(this.texts[r]) + } + // If find a letter, stop right-trimming + if (this.texts[r].match(/[^\s]/)) { + return false } - measures.push({ - width: context.textMeasure.measureTextWidth(this.texts[i], textNodeContext.attributeState), + } + return true + } + + measureText(context: Context): void { + for (let i = 0; i < this.texts.length; i++) { + this.textMeasures.push({ + width: context.textMeasure.measureTextWidth(this.texts[i], this.contexts[i].attributeState), length: this.texts[i].length }) } - - return measures } - put(context: Context, charSpace: number, textWidths: number[] | null): Point { - let i, textNode + put(context: Context, charSpace: number): Point { + let i, textNode, textNodeContext, textMeasure + + const alreadySeen: Element[] = [] - let strokeRGB: RGBColor const xs = [], - ys = [], - textNodeContexts = [] + ys = [] let currentTextX = this.originX, currentTextY = this.originY let minX = currentTextX, maxX = currentTextX for (i = 0; i < this.textNodes.length; i++) { textNode = this.textNodes[i] + textNodeContext = this.contexts[i] + textMeasure = this.textMeasures[i] || { + width: context.textMeasure.measureTextWidth(this.texts[i], this.contexts[i].attributeState), + length: this.texts[i].length + } let x = currentTextX let y = currentTextY - let textNodeContext - if (textNode.nodeName === '#text') { - textNodeContext = context - } else { - textNodeContext = context.clone() - parseAttributes(textNodeContext, this.textNode, textNode) - - const tSpanStrokeColor = getAttribute(textNode, context.styleSheets, 'stroke') - if (tSpanStrokeColor) { - strokeRGB = new RGBColor(tSpanStrokeColor) - if (strokeRGB.ok) { - textNodeContext.attributeState.stroke = new ColorFill(strokeRGB) - } - } - const strokeWidth = getAttribute(textNode, context.styleSheets, 'stroke-width') - if (strokeWidth !== void 0) { - textNodeContext.attributeState.strokeWidth = parseFloat(strokeWidth) - } + if (textNode.nodeName !== '#text') { + if (!alreadySeen.includes(textNode)) { + alreadySeen.push(textNode) - const tSpanDx = textNode.getAttribute('dx') - if (tSpanDx !== null) { - x += toPixels(tSpanDx, textNodeContext.attributeState.fontSize) - } + const tSpanDx = textNode.getAttribute('dx') + if (tSpanDx !== null) { + x += toPixels(tSpanDx, textNodeContext.attributeState.fontSize) + } - const tSpanDy = textNode.getAttribute('dy') - if (tSpanDy !== null) { - y += toPixels(tSpanDy, textNodeContext.attributeState.fontSize) + const tSpanDy = textNode.getAttribute('dy') + if (tSpanDy !== null) { + y += toPixels(tSpanDy, textNodeContext.attributeState.fontSize) + } } } - textNodeContexts[i] = textNodeContext - xs[i] = x ys[i] = y - currentTextX = - x + - (textWidths - ? textWidths[i] - : context.textMeasure.measureTextWidth(this.texts[i], textNodeContext.attributeState)) + currentTextX = x + textMeasure.width + textMeasure.length * charSpace currentTextY = y @@ -147,21 +132,19 @@ export class TextChunk { for (i = 0; i < this.textNodes.length; i++) { textNode = this.textNodes[i] + textNodeContext = this.contexts[i] if (textNode.nodeName !== '#text') { - const tSpanVisibility = - getAttribute(textNode, context.styleSheets, 'visibility') || - context.attributeState.visibility - if (tSpanVisibility === 'hidden') { + if (textNodeContext.attributeState.visibility === 'hidden') { continue } } context.pdf.saveGraphicsState() - applyAttributes(textNodeContexts[i], context, textNode) + applyAttributes(textNodeContext, context, textNode) - const alignmentBaseline = textNodeContexts[i].attributeState.alignmentBaseline - const textRenderingMode = getTextRenderingMode(textNodeContexts[i].attributeState) + const alignmentBaseline = textNodeContext.attributeState.alignmentBaseline + const textRenderingMode = getTextRenderingMode(textNodeContext.attributeState) context.pdf.text(this.texts[i], xs[i] - textOffset, ys[i], { baseline: mapAlignmentBaseline(alignmentBaseline), angle: context.transform, diff --git a/test/common/tests.js b/test/common/tests.js index dc334a5b..c647c379 100644 --- a/test/common/tests.js +++ b/test/common/tests.js @@ -31,6 +31,7 @@ window.tests = [ 'image-svg-urls', 'line-default-coordinates', 'markers', + 'nested-tspans', 'opacity-and-rgba', 'path-arc-support', 'pattern-units', diff --git a/test/specs/display-none-and-visibility-inheritance/reference.pdf b/test/specs/display-none-and-visibility-inheritance/reference.pdf index 1d6849f0..1fea7bd5 100644 Binary files a/test/specs/display-none-and-visibility-inheritance/reference.pdf and b/test/specs/display-none-and-visibility-inheritance/reference.pdf differ diff --git a/test/specs/nested-tspans/reference.pdf b/test/specs/nested-tspans/reference.pdf new file mode 100644 index 00000000..2f166d5e Binary files /dev/null and b/test/specs/nested-tspans/reference.pdf differ diff --git a/test/specs/nested-tspans/spec.svg b/test/specs/nested-tspans/spec.svg new file mode 100644 index 00000000..f5373f65 --- /dev/null +++ b/test/specs/nested-tspans/spec.svg @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500" viewBox="0 0 500 500" font-family="times"> + <text font-size="24" y="20">Stroke <tspan stroke-width="0.5" stroke="red">inherited <tspan fill="green">correctly</tspan></tspan></text> + <text font-size="24" y="60">Font-size<tspan font-size="16"> inherited<tspan font-size="24"> correctly</tspan></tspan></text> + <text font-size="24" y="100">Text <tspan x="100">absolute x offset<tspan> child</tspan> sibling</tspan></text> + <text font-size="24" y="140">Text <tspan dx="20">relative x offset<tspan> child</tspan> sibling</tspan></text> + <text font-size="24" y="180">Text <tspan y="190">absolute y offset<tspan> child</tspan> sibling</tspan></text> + <text font-size="24" y="220">Text <tspan dy="10">relative y offset<tspan> child</tspan> sibling</tspan></text> + <text font-size="24" y="260">Preserve <tspan xml:space="preserve">passed to<tspan> children <tspan xml:space="default"> default </tspan></tspan></tspan> sibling</text> + <text font-size="24" y="300">Default <tspan xml:space="default">passed to<tspan> children<tspan xml:space="preserve"> preserve</tspan></tspan></tspan> sibling</text> + <text font-size="24" y="340">Nested titles <tspan><title>Don't render!ignored + lorem i p su m + diff --git a/test/specs/text-placement/reference.pdf b/test/specs/text-placement/reference.pdf index 13de5538..0a573239 100644 Binary files a/test/specs/text-placement/reference.pdf and b/test/specs/text-placement/reference.pdf differ diff --git a/test/specs/title-element/reference.pdf b/test/specs/title-element/reference.pdf index 3848452d..9a0f94fa 100644 Binary files a/test/specs/title-element/reference.pdf and b/test/specs/title-element/reference.pdf differ