Răsfoiți Sursa

Print invoice notes written as plain lines, as AI Write saves them (#515)

* Print invoice notes written as plain lines, as AI Write saves them

* Draw a quoted note once, however deep the quotes nest
Bernt Christian Egeland 1 zi în urmă
părinte
comite
d97bf42f10

+ 51 - 0
src/__tests__/features/vehicles/invoice-pdf-notes.test.tsx

@@ -0,0 +1,51 @@
+// @vitest-environment node
+/**
+ * The notes on a printed invoice. AI Write saves its explanation as plain
+ * lines joined by <br>, with no paragraphs; such a note printed as an empty
+ * Notes box the height of a page. Each note here is printed to a real PDF and
+ * its words read back out of it.
+ */
+import { Document, Page, renderToBuffer } from '@react-pdf/renderer'
+import { extractText } from 'unpdf'
+import { describe, expect, it } from 'vitest'
+import { HtmlToPdf } from '@/features/vehicles/Components/invoice-pdf/Notes'
+
+async function printed(html: string): Promise<string> {
+  const buffer = await renderToBuffer(
+    <Document>
+      <Page size="A4">
+        <HtmlToPdf html={html} baseStyle={{ fontSize: 9 }} fontBold="Helvetica-Bold" />
+      </Page>
+    </Document>
+  )
+  const { text } = await extractText(new Uint8Array(buffer), { mergePages: true })
+  return text
+}
+
+describe('notes on the invoice PDF', () => {
+  it('prints a note written as lines with breaks, as AI Write saves it', async () => {
+    const text = await printed(
+      'SERVICE DESCRIPTION<br><br>Vehicle: 2023 Toyota Camry XSE<br>We replaced the <strong>front brake pads</strong>.'
+    )
+    expect(text).toContain('SERVICE DESCRIPTION')
+    expect(text).toContain('Vehicle: 2023 Toyota Camry XSE')
+    expect(text).toContain('front brake pads')
+  })
+
+  it('still prints paragraphs, lists and the words between them', async () => {
+    const text = await printed(
+      '<p>First paragraph.</p>Loose words between.<ul><li>A listed part</li></ul><blockquote>Quoted line</blockquote>'
+    )
+    expect(text).toContain('First paragraph.')
+    expect(text).toContain('Loose words between.')
+    expect(text).toContain('A listed part')
+    expect(text).toContain('Quoted line')
+  })
+
+  it('prints a deeply nested quote once, without the work doubling per level', async () => {
+    const depth = 22
+    const html = `${'<blockquote>'.repeat(depth)}Deep quote${'</blockquote>'.repeat(depth)}`
+    const text = await printed(html)
+    expect(text.match(/Deep quote/g)).toHaveLength(1)
+  })
+})

+ 40 - 3
src/features/vehicles/Components/invoice-pdf/Notes.tsx

@@ -35,6 +35,9 @@ function tokenize(html: string): Token[] {
   return tokens
 }
 
+/** Tags drawn as their own Text or View; everything else sits inside a line of text. */
+const BLOCK_TAGS = new Set(['p', 'h2', 'h3', 'ul', 'ol', 'li', 'blockquote'])
+
 interface ASTNode {
   tag: string
   children: (ASTNode | string)[]
@@ -93,9 +96,43 @@ export function HtmlToPdf({
     })
   }
 
+  /**
+   * Children of a View. react-pdf draws text only inside a Text, so a run of
+   * loose words, bold or line breaks between the blocks goes into one. Notes
+   * written as plain lines with <br> and no <p> (as AI Write saves them)
+   * printed only their line breaks before: an empty, page-long Notes box.
+   */
+  function renderBlocks(nodes: (ASTNode | string)[]): React.ReactNode[] {
+    const out: React.ReactNode[] = []
+    let run: (ASTNode | string)[] = []
+    const flush = () => {
+      // Whitespace between two blocks was never drawn and still is not.
+      if (run.some((n) => typeof n !== 'string' || n.trim() !== '')) {
+        out.push(
+          <Text key={nextKey()} style={base}>
+            {renderNodes(run)}
+          </Text>
+        )
+      }
+      run = []
+    }
+    for (const node of nodes) {
+      if (typeof node !== 'string' && BLOCK_TAGS.has(node.tag)) {
+        flush()
+        out.push(renderNode(node))
+      } else {
+        run.push(node)
+      }
+    }
+    flush()
+    return out
+  }
+
   function renderNode(node: ASTNode): React.ReactNode {
     const k = nextKey()
-    const children = renderNodes(node.children)
+    // A quote lays its children out as blocks itself; rendering them here too
+    // would draw every nested quote twice, and twice again per level.
+    const children = node.tag === 'blockquote' ? [] : renderNodes(node.children)
 
     switch (node.tag) {
       case 'p':
@@ -196,7 +233,7 @@ export function HtmlToPdf({
               opacity: 0.8,
             }}
           >
-            {children}
+            {renderBlocks(node.children)}
           </View>
         )
       case 'br':
@@ -206,5 +243,5 @@ export function HtmlToPdf({
     }
   }
 
-  return <View>{renderNodes(ast)}</View>
+  return <View>{renderBlocks(ast)}</View>
 }