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 {
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 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 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 @@
+
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