diff --git a/examples/test-pdf-gen/markdown_rendering_example.pdf b/examples/test-pdf-gen/markdown_rendering_example.pdf index cbf9209..b7727de 100644 Binary files a/examples/test-pdf-gen/markdown_rendering_example.pdf and b/examples/test-pdf-gen/markdown_rendering_example.pdf differ diff --git a/examples/test-pdf-gen/src/md-text.ts b/examples/test-pdf-gen/src/md-text.ts new file mode 100644 index 0000000..45ca201 --- /dev/null +++ b/examples/test-pdf-gen/src/md-text.ts @@ -0,0 +1,43 @@ +// export const mdString = "# jsPDF Markdown Renderer\r\n\r\nA jsPDF utility to render Markdown directly into formatted PDFs with custom designs.\r\n\r\n## Table of Contents\r\n\r\n- [Installation](#installation)\r\n- [Usage](#usage)\r\n- [API](#api)\r\n- [Examples](#examples)\r\n- [Contributing](#contributing)\r\n- [License](#license)\r\n\r\n## Installation\r\n\r\nTo install the library, you can use npm:\r\n\r\n```sh\r\nnpm install jspdf-md-renderer\r\n```\r\n\r\n## Usage\r\n\r\n### Basic Example\r\n\r\nHere is a basic example of how to use the library to generate a PDF from Markdown content:\r\n\r\n```ts\r\nimport { jsPDF } from 'jspdf';\r\nimport { MdTextRender } from 'jspdf-md-renderer';\r\n\r\nconst mdString = `\r\n# Main Title\r\n\r\nThis is a brief introduction paragraph. It sets the tone for the document and introduces the main topic in a concise manner.\r\n\r\n## Section 1: Overview\r\n\r\nHere is a medium-length paragraph that goes into more detail about the first section. It explains the context, provides background information, and sets up the discussion for the subsections.\r\n\r\n## Section 2: Lists and Examples\r\n\r\nThis section showcases how to create simple and nested lists.\r\n\r\n### Simple List\r\n\r\n- Item 1\r\n- Item 2\r\n- Item 3\r\n\r\n### Nested List\r\n\r\n1. First Level 1\r\n - First Level 2\r\n - First Level 3\r\n2. Second Level 1\r\n - Second Level 2\r\n - Another Second Level 2\r\n - Nested deeper\r\n\r\n### Mixed List Example\r\n\r\n- Topic 1\r\n 1. Subtopic 1.1\r\n 2. Subtopic 1.2\r\n- Topic 2\r\n - Subtopic 2.1\r\n - Subtopic 2.2\r\n 1. Nested Subtopic 2.2.1\r\n 2. Nested Subtopic 2.2.2\r\n\r\n`;\r\n\r\nconst generatePDF = async () => {\r\n const doc = new jsPDF({\r\n unit: 'mm',\r\n format: 'a4',\r\n orientation: 'portrait',\r\n });\r\n\r\n const options = {\r\n cursor: { x: 10, y: 10 },\r\n page: {\r\n format: 'a4',\r\n unit: 'mm',\r\n orientation: 'portrait',\r\n maxContentWidth: 190,\r\n maxContentHeight: 277,\r\n lineSpace: 1.5,\r\n defaultLineHeightFactor: 1.2,\r\n defaultFontSize: 12,\r\n defaultTitleFontSize: 14,\r\n topmargin: 10,\r\n xpading: 10,\r\n xmargin: 10,\r\n indent: 10,\r\n },\r\n font: {\r\n bold: { name: 'helvetica', style: 'bold' },\r\n regular: { name: 'helvetica', style: 'normal' },\r\n light: { name: 'helvetica', style: 'light' },\r\n },\r\n endCursorYHandler: (y) => {\r\n console.log('End cursor Y position:', y);\r\n },\r\n };\r\n\r\n await MdTextRender(doc, mdString, options);\r\n doc.save('example.pdf');\r\n};\r\n\r\ngeneratePDF();\r\n```\r\n\r\n## API\r\n\r\n### `MdTextRender`\r\n\r\nRenders parsed markdown text into a jsPDF document.\r\n\r\n#### Parameters\r\n\r\n- `doc`: The jsPDF document instance.\r\n- `text`: The markdown content to render.\r\n- `options`: The render options (fonts, page margins, etc.).\r\n\r\n### `MdTextParser`\r\n\r\nParses markdown into tokens and converts to a custom parsed structure.\r\n\r\n#### Parameters\r\n\r\n- `text`: The markdown content to parse.\r\n\r\n#### Returns\r\n\r\n- `Promise`: Parsed markdown elements.\r\n\r\n\r\n## Supported Markdown Elements\r\n\r\nThe following Markdown elements are currently supported by `jspdf-md-renderer`:\r\n\r\n- **Headings**: `#`, `##`, `###`, etc.\r\n- **Paragraphs**\r\n- **Lists**:\r\n - Unordered lists: `-`, `*`, `+`\r\n - Ordered lists: `1.`, `2.`, `3.`, etc.\r\n\r\n\r\n## Examples\r\n\r\nYou can find more examples in the [examples](examples/test-pdf-gen) directory.\r\n\r\n## Contributing\r\n\r\nContributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) first.\r\n\r\n## License\r\n\r\nThis project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.\r\n" +export const mdString = "# Sample Markdown Document\n\n## Headings\n\n# Heading Level 1\n## Heading Level 2\n### Heading Level 3\n#### Heading Level 4\n##### Heading Level 5\n###### Heading Level 6\n\n---\n\n## Emphasis\n\n- *Italic* text using asterisks.\n- _Italic_ text using underscores.\n- **Bold** text using double asterisks.\n- __Bold__ text using double underscores.\n- ***Bold and Italic*** text using triple asterisks.\n\n---\n\n## Lists\n\n### Unordered List\n- Item 1\n - Subitem 1.1\n - Subitem 1.2\n- Item 2\n\n### Ordered List\n5. Item 1\n6. Item 2\n 7. Subitem 2.1\n 2. Subitem 2.2\n\n---\n\n## Links and Images\n\n[OpenAI](https://openai.com)\n\n![Sample Image](https://via.placeholder.com/150 \"Placeholder Image\")\n\n---\n\n## Code\n\n### Inline Code\nThis is `inline code`.\n\n### Code Block\n```javascript\nfunction greet(name) {\n return `Hello, ${name}!`;\n}\nconsole.log(greet('Markdown'));\n```" +// export const mdString = ` +// # Main Title + +// This is a brief introduction paragraph. It sets the tone for the document and introduces the main topic in a concise manner. + +// ## Section 1: Overview + +// Here is a medium-length paragraph that goes into more detail about the first section. It explains the context, provides background information, and sets up the discussion for the subsections. + +// ## Section 2: Lists and Examples + +// This section showcases how to create simple and nested lists. + +// ### Simple List + +// - Item 1 +// - Item 2 +// - Item 3 + +// ### Nested List + +// 1. First Level 1 +// - First Level 2 +// - First Level 3 +// 2. Second Level 1 +// - Second Level 2 +// - Another Second Level 2 +// - Nested deeper + +// ### Mixed List Example + +// - Topic 1 +// 1. Subtopic 1.1 +// 2. Subtopic 1.2 +// - Topic 2 +// - Subtopic 2.1 +// - Subtopic 2.2 +// 1. Nested Subtopic 2.2.1 +// 2. Nested Subtopic 2.2.2 + +// `; diff --git a/examples/test-pdf-gen/src/pdfGenerator.ts b/examples/test-pdf-gen/src/pdfGenerator.ts index f6717b0..15fb0ad 100644 --- a/examples/test-pdf-gen/src/pdfGenerator.ts +++ b/examples/test-pdf-gen/src/pdfGenerator.ts @@ -1,59 +1,7 @@ import { RenderOption } from "jspdf-md-renderer/types"; import { MdTextRender } from "jspdf-md-renderer"; import jsPDF from "jspdf"; - -const mdString = ` -# Main Title - -This is a brief introduction paragraph. It sets the tone for the document and introduces the main topic in a concise manner. - -## Section 1: Overview - -Here is a medium-length paragraph that goes into more detail about the first section. It explains the context, provides background information, and sets up the discussion for the subsections. - -### Subsection 1.1: Details - -A longer paragraph with detailed explanations about the subsection. This paragraph is significantly longer to showcase how text can expand on key ideas, provide examples, and explain technical concepts in a way that engages the reader. It includes a lot of descriptive content to fill out the space and make the information comprehensive. - -#### Sub-subsection 1.1.1: Specifics - -An even shorter paragraph. Sometimes, brevity is key. - -## Section 2: Lists and Examples - -This section showcases how to create simple and nested lists. - -### Simple List - -- Item 1 -- Item 2 -- Item 3 - -### Nested List - -1. First Level 1 - - First Level 2 - - First Level 3 -2. Second Level 1 - - Second Level 2 - - Another Second Level 2 - - Nested deeper - -### Mixed List Example - -- Topic 1 - 1. Subtopic 1.1 - 2. Subtopic 1.2 -- Topic 2 - - Subtopic 2.1 - - Subtopic 2.2 - 1. Nested Subtopic 2.2.1 - 2. Nested Subtopic 2.2.2 - -## Section 3: Conclusion - -Finally, we wrap up with a short paragraph that highlights the key takeaways and invites the reader to reflect on the content. -` +import { mdString } from "./md-text"; export const pdfGenerator = async () => { const doc = new jsPDF({ diff --git a/src/enums/mdTokenType.ts b/src/enums/mdTokenType.ts index 7871446..144848d 100644 --- a/src/enums/mdTokenType.ts +++ b/src/enums/mdTokenType.ts @@ -5,6 +5,7 @@ export enum MdTokenType { ListItem = 'list_item', Blockquote = 'blockquote', Code = 'code', + CodeSpan = 'codespan', Table = 'table', Html = 'html', Hr = 'hr', @@ -15,4 +16,5 @@ export enum MdTokenType { TableHeader = 'table_header', TableCell = 'table_cell', Raw = 'raw', + Text = 'text', } diff --git a/src/parser/MdTextParser.ts b/src/parser/MdTextParser.ts index 874a851..e1366b9 100644 --- a/src/parser/MdTextParser.ts +++ b/src/parser/MdTextParser.ts @@ -23,18 +23,21 @@ export const MdTextParser = async (text: string): Promise => { const convertTokens = (tokens: TokensList): ParsedElement[] => { const parsedElements: ParsedElement[] = []; tokens.forEach((token) => { - const handler = tokenHandlers[token.type]; - if (handler) { - parsedElements.push(handler(token)); - } else { - parsedElements.push({ type: MdTokenType.Raw, content: token.raw }); + try { + const handler = tokenHandlers[token.type]; + if (handler) { + parsedElements.push(handler(token)); + } else { + parsedElements.push({ + type: MdTokenType.Raw, + content: token.raw, + }); + } + } catch (error) { + console.error('Failed to handle token ==>', token, error); } }); - return parsedElements.map((element) => - element.type === MdTokenType.Raw && element.content === '\n\n' - ? { ...element, content: element.content.replace('\n\n', '\n') } - : element, - ); + return parsedElements; }; /** @@ -45,13 +48,17 @@ const tokenHandlers: Record ParsedElement> = { type: MdTokenType.Heading, depth: token.depth, content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], }), [MdTokenType.Paragraph]: (token) => ({ type: MdTokenType.Paragraph, content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], }), [MdTokenType.List]: (token) => ({ type: MdTokenType.List, + ordered: token.ordered, + start: token.start, items: token.items ? convertTokens(token.items) : [], }), [MdTokenType.ListItem]: (token) => ({ @@ -86,13 +93,31 @@ const tokenHandlers: Record ParsedElement> = { type: MdTokenType.Link, href: token.href, text: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], }), [MdTokenType.Strong]: (token) => ({ type: MdTokenType.Strong, content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], }), [MdTokenType.Em]: (token) => ({ type: MdTokenType.Em, content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], + }), + [MdTokenType.Text]: (token) => ({ + type: MdTokenType.Text, + content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], + }), + [MdTokenType.Hr]: (token) => ({ + type: MdTokenType.Hr, + content: token.raw, + items: token.tokens ? convertTokens(token.tokens) : [], + }), + [MdTokenType.CodeSpan]: (token) => ({ + type: MdTokenType.CodeSpan, + content: token.text, + items: token.tokens ? convertTokens(token.tokens) : [], }), }; diff --git a/src/renderer/MdTextRender.ts b/src/renderer/MdTextRender.ts index d101345..4b802b0 100644 --- a/src/renderer/MdTextRender.ts +++ b/src/renderer/MdTextRender.ts @@ -27,7 +27,6 @@ export const MdTextRender = async ( ) => { const parsedElements = await MdTextParser(text); console.log(parsedElements); - console.log(doc); let y = options.cursor.y; const x = options.cursor.x; @@ -53,10 +52,10 @@ export const MdTextRender = async ( switch (element.type) { case MdTokenType.Heading: - y = renderHeading(doc, element, x, y, indent, options); + y = renderHeading(doc, element, x, y, indent, options, renderElement); break; case MdTokenType.Paragraph: - y = renderParagraph(doc, element, x, y, indent, options); + y = renderParagraph(doc, element, x, y, indent, options, renderElement); break; case MdTokenType.List: y = renderList( @@ -80,6 +79,7 @@ export const MdTextRender = async ( ); break; case MdTokenType.Raw: + case MdTokenType.Text: y = renderRawItem( doc, element, @@ -88,14 +88,15 @@ export const MdTextRender = async ( indentLevel, hasRawBullet, options, + renderElement, ); break; default: console.warn( `Warning: Unsupported element type encountered: ${element.type}. - If you believe this element type should be supported, please create an issue at: - https://github.com/JeelGajera/jspdf-md-renderer/issues - with details of the element and expected behavior. Thank you for helping improve this library!`, + If you believe this element type should be supported, please create an issue at: + https://github.com/JeelGajera/jspdf-md-renderer/issues + with details of the element and expected behavior. Thank you for helping improve this library!`, ); break; } diff --git a/src/renderer/components/heading.ts b/src/renderer/components/heading.ts index ba35b2a..20e8447 100644 --- a/src/renderer/components/heading.ts +++ b/src/renderer/components/heading.ts @@ -13,15 +13,27 @@ const renderHeading = ( y: number, indent: number, options: RenderOption, + parentElementRenderer: ( + element: ParsedElement, + indentLevel: number, + hasRawBullet?: boolean, + ) => number, ): number => { const size = 6 - (element?.depth ?? 0) > 0 ? 6 - (element?.depth ?? 0) : 0; // doc.setFont(options.font.regular.name, options.font.regular.style); doc.setFontSize(options.page.defaultFontSize + size); - doc.text(element?.content ?? '', x + indent, y, { - align: 'left', - maxWidth: options.page.maxContentWidth - indent, - }); - y += 1.5 * getCharHight(doc, options); + if (element?.items && element?.items.length > 0) { + for (const item of element?.items ?? []) { + y = parentElementRenderer(item, indent, false); + } + } else { + doc.text(element?.content ?? '', x + indent, y, { + align: 'left', + maxWidth: options.page.maxContentWidth - indent, + }); + y += 1.5 * getCharHight(doc, options); + } + // doc.setFont(options.font.light.name, options.font.light.style); doc.setFontSize(options.page.defaultFontSize); return y; diff --git a/src/renderer/components/paragraph.ts b/src/renderer/components/paragraph.ts index 196405f..dfca81e 100644 --- a/src/renderer/components/paragraph.ts +++ b/src/renderer/components/paragraph.ts @@ -15,63 +15,74 @@ const renderParagraph = ( y: number, indent: number, options: RenderOption, + parentElementRenderer: ( + element: ParsedElement, + indentLevel: number, + hasRawBullet?: boolean, + ) => number, ): number => { doc.setFontSize(options.page.defaultFontSize); // doc.setFont(options.font.light.name, options.font.light.style); let content = element.content; const lineHeight = doc.getTextDimensions('A').h * options.page.defaultLineHeightFactor; - if ( - y + - doc.splitTextToSize( + if (element?.items && element?.items.length > 0) { + for (const item of element?.items ?? []) { + y = parentElementRenderer(item, indent, false); + } + } else { + if ( + y + + doc.splitTextToSize( + content ?? '', + options.page.maxContentWidth - indent, + ).length * + lineHeight - + 3 * lineHeight >= + options.page.maxContentHeight + ) { + // ADD Possible text to Page bottom + const contentLeft: string[] = doc.splitTextToSize( content ?? '', options.page.maxContentWidth - indent, - ).length * - lineHeight - - 3 * lineHeight >= - options.page.maxContentHeight - ) { - // ADD Possible text to Page bottom - const contentLeft: string[] = doc.splitTextToSize( - content ?? '', - options.page.maxContentWidth - indent, - ); - const possibleContentLines: string[] = []; - const possibleContentY = y; - for (let j = 0; j < contentLeft.length; j++) { - if (y - 2 * lineHeight < options.page.maxContentHeight) { - possibleContentLines.push(contentLeft[j]); - y += options.page.lineSpace; - } else { - // set left content to move next page - if (j <= contentLeft.length - 1) { - content = contentLeft.slice(j).join(''); + ); + const possibleContentLines: string[] = []; + const possibleContentY = y; + for (let j = 0; j < contentLeft.length; j++) { + if (y - 2 * lineHeight < options.page.maxContentHeight) { + possibleContentLines.push(contentLeft[j]); + y += options.page.lineSpace; + } else { + // set left content to move next page + if (j <= contentLeft.length - 1) { + content = contentLeft.slice(j).join(''); + } + break; } - break; } + if (possibleContentLines.length > 0) { + y = justifyText( + doc, + possibleContentLines.join(' '), + x + indent, + possibleContentY, + options.page.maxContentWidth - indent, + options.page.defaultLineHeightFactor, + ); + } + HandlePageBreaks(doc, options); + y = options.page.topmargin; } - if (possibleContentLines.length > 0) { - y = justifyText( + y = + justifyText( doc, - possibleContentLines.join(' '), + content ?? '', x + indent, - possibleContentY, + y, options.page.maxContentWidth - indent, options.page.defaultLineHeightFactor, - ); - } - HandlePageBreaks(doc, options); - y = options.page.topmargin; + ) + getCharHight(doc, options); } - y = - justifyText( - doc, - content ?? '', - x + indent, - y, - options.page.maxContentWidth - indent, - options.page.defaultLineHeightFactor, - ) + getCharHight(doc, options); return y; }; diff --git a/src/renderer/components/rawItem.ts b/src/renderer/components/rawItem.ts index b076a25..2f56046 100644 --- a/src/renderer/components/rawItem.ts +++ b/src/renderer/components/rawItem.ts @@ -3,6 +3,7 @@ import { ParsedElement } from '../../types/parsedElement'; import { RenderOption } from '../../types/renderOption'; import { HandlePageBreaks } from '../../utils/handlePageBreak'; import { getCharHight } from '../../utils/doc-helpers'; +import { justifyText } from '../../utils/justifyText'; const renderRawItem = ( doc: jsPDF, @@ -12,23 +13,43 @@ const renderRawItem = ( indentLevel: number, hasRawBullet: boolean, options: RenderOption, + parentElementRenderer: ( + element: ParsedElement, + indentLevel: number, + hasRawBullet?: boolean, + ) => number, ): number => { - const indent = indentLevel * options.page.indent; - const bullet = hasRawBullet ? '\u2022 ' : ''; // unicode for bullet point - const lines = doc.splitTextToSize( - bullet + element.content, - options.page.maxContentWidth - indent, - ); - if ( - y + lines.length * getCharHight(doc, options) >= - options.page.maxContentHeight - ) { - HandlePageBreaks(doc, options); - y = options.page.topmargin; - } - doc.text(lines, x + indent, y); - y += lines.length * getCharHight(doc, options); + if (element?.items && element?.items.length > 0) { + for (const item of element?.items ?? []) { + y = parentElementRenderer(item, indentLevel, hasRawBullet); + } + } else { + const indent = indentLevel * options.page.indent; + const bullet = hasRawBullet ? '\u2022 ' : ''; // unicode for bullet point + const lines = doc.splitTextToSize( + bullet + element.content, + options.page.maxContentWidth - indent, + ); + if ( + y + lines.length * getCharHight(doc, options) >= + options.page.maxContentHeight + ) { + HandlePageBreaks(doc, options); + y = options.page.topmargin; + } + y = + justifyText( + doc, + bullet + element.content, + x + indent, + y, + options.page.maxContentWidth - indent, + options.page.defaultLineHeightFactor, + ) + getCharHight(doc, options); + // doc.text(lines, x + indent, y); + // y += lines.length * getCharHight(doc, options); + } return y; }; diff --git a/src/types/parsedElement.ts b/src/types/parsedElement.ts index 75bd150..0013514 100644 --- a/src/types/parsedElement.ts +++ b/src/types/parsedElement.ts @@ -4,6 +4,8 @@ export type ParsedElement = { content?: string; depth?: number; items?: ParsedElement[]; + ordered?: boolean; + start?: number | string; lang?: string; code?: string; src?: string;