From 0e228dabca10d71d251c9ffb4ce19a38f6ddc4a9 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Thu, 5 Feb 2026 16:51:38 +1100 Subject: [PATCH 1/6] use Header widget for PDF outline/TOC --- .../lib/src/browser/pdf_builder.dart | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index ce79f7f..9edf485 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -727,6 +727,26 @@ class PdfBuilder { } Future> _buildBlockChild(RenderNode node) async { + // Special handling for top-level headers to emit PDF outlines/TOC entries. + if (node.tagName == 'h1') { + final text = _collectText(node).trim(); + if (text.isEmpty) { + return []; + } + + return [ + pw.Header( + level: 0, + title: text, + text: text, + textStyle: _mapTextStyle(node.style), + decoration: _buildBoxDecoration(node.style), + margin: node.style.margin, + padding: node.style.padding, + ), + ]; + } + // This node is a block element. // We need to apply its styles (padding, margin, border, background) // and then process its children. From 15b2c468108d870d1f86ead534cdd855b7a3d397 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Thu, 5 Feb 2026 21:25:36 +1100 Subject: [PATCH 2/6] allow basic styling of code & span elements --- .../lib/src/browser/pdf_builder.dart | 108 ++++++++++++++++++ .../lib/src/htmltagstyles.dart | 46 ++++++++ 2 files changed, 154 insertions(+) diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index 9edf485..32f72f0 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -969,6 +969,21 @@ class PdfBuilder { ]; } + String _collectInlineText(RenderNode node) { + if (node.text != null) return node.text!; + final buffer = StringBuffer(); + for (final child in node.children) { + buffer.write(_collectInlineText(child)); + } + return buffer.toString(); + } + + bool _hasClass(RenderNode node, String className) { + final classAttr = node.attributes['class']; + if (classAttr == null || classAttr.isEmpty) return false; + return classAttr.split(RegExp(r'\s+')).contains(className); + } + void _collectInlineSpans(RenderNode node, List spans) { if (node.display == Display.none) return; @@ -1009,6 +1024,99 @@ class PdfBuilder { return; } + if (node.tagName == 'span') { + InlineTagStyle? classStyle; + if (node.attributes.containsKey('class')) { + for (final className in node.attributes['class']!.split(RegExp(r'\s+'))) { + if (className.isEmpty) continue; + final style = tagStyle.inlineClassStyles[className]; + if (style != null) { + classStyle = style; + break; + } + } + } + + if (classStyle != null) { + if (spans.isNotEmpty) { + final lastSpan = spans.last; + if (lastSpan is pw.TextSpan) { + final lastText = lastSpan.text ?? ''; + if (lastText.isNotEmpty && !lastText.endsWith(' ')) { + spans.add(const pw.TextSpan(text: ' ')); + } + } + } + + final textContent = _collectInlineText(node).replaceAll(RegExp(r'\s+'), ' '); + + if (textContent.isNotEmpty) { + final baseStyle = _mapTextStyle(node.style).copyWith( + background: null, + color: classStyle.textColor ?? _mapTextStyle(node.style).color, + ); + final bgColor = classStyle.backgroundColor ?? node.style.backgroundColor; + final borderColor = classStyle.borderColor; + final borderWidth = classStyle.borderWidth; + final padding = classStyle.padding; + + spans.add(pw.WidgetSpan( + baseline: 0, + child: pw.Container( + padding: pw.EdgeInsets.all(padding), + decoration: pw.BoxDecoration( + color: bgColor, + border: borderColor != null && borderWidth > 0 + ? pw.Border.all(color: borderColor, width: borderWidth) + : null, + ), + child: pw.Text(textContent, style: baseStyle), + ), + )); + } + return; + } + } + + if (node.tagName == 'code' && node.display == Display.inline) { + if (spans.isNotEmpty) { + final lastSpan = spans.last; + if (lastSpan is pw.TextSpan) { + final lastText = lastSpan.text ?? ''; + if (lastText.isNotEmpty && !lastText.endsWith(' ')) { + spans.add(const pw.TextSpan(text: ' ')); + } + } + } + + var textContent = _collectInlineText(node); + textContent = textContent.replaceAll(RegExp(r'\s+'), ' '); + + if (textContent.isNotEmpty) { + final baseStyle = _mapTextStyle(node.style).copyWith(background: null); + final bgColor = + tagStyle.inlineCodeBackgroundColor ?? node.style.backgroundColor; + final borderColor = tagStyle.inlineCodeBorderColor; + final borderWidth = tagStyle.inlineCodeBorderWidth; + final padding = tagStyle.inlineCodePadding; + + spans.add(pw.WidgetSpan( + baseline: 0, + child: pw.Container( + padding: pw.EdgeInsets.all(padding), + decoration: pw.BoxDecoration( + color: bgColor, + border: borderColor != null && borderWidth > 0 + ? pw.Border.all(color: borderColor, width: borderWidth) + : null, + ), + child: pw.Text(textContent, style: baseStyle), + ), + )); + } + return; + } + // If this is a text node, add its text with current style if (node.text != null && node.text!.isNotEmpty) { // Normalize whitespace like browsers do diff --git a/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart b/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart index cbdd8c3..6f5abad 100644 --- a/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart +++ b/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart @@ -6,6 +6,22 @@ import 'package:htmltopdfwidgets/htmltopdfwidgets.dart'; /// Return a [Widget] to render as the checkbox. typedef CheckboxBuilder = Widget Function(bool isChecked); +class InlineTagStyle { + final PdfColor? textColor; + final PdfColor? backgroundColor; + final PdfColor? borderColor; + final double borderWidth; + final double padding; + + const InlineTagStyle({ + this.textColor, + this.backgroundColor, + this.borderColor, + this.borderWidth = 1.0, + this.padding = 2.0, + }); +} + //apply custom styles to html stylee class HtmlTagStyle { //bold style that will merge with default style @@ -67,6 +83,26 @@ class HtmlTagStyle { final PdfColor codeblockColor; // The decoration style that will merge with default style final BoxDecoration? codeDecoration; + // Inline code background color + final PdfColor? inlineCodeBackgroundColor; + // Inline code border color + final PdfColor? inlineCodeBorderColor; + // Inline code border width + final double inlineCodeBorderWidth; + // Inline code padding + final double inlineCodePadding; + // Inline class-based styles (applies to span with class) + final Map inlineClassStyles; + // Inline button text color + final PdfColor? inlineButtonTextColor; + // Inline button background color + final PdfColor? inlineButtonBackgroundColor; + // Inline button border color + final PdfColor? inlineButtonBorderColor; + // Inline button border width + final double inlineButtonBorderWidth; + // Inline button padding + final double inlineButtonPadding; /// The height of the divider in a PDF document. final double dividerHight; @@ -133,6 +169,16 @@ class HtmlTagStyle { this.codeBlockBackgroundColor = PdfColors.red, this.codeblockColor = PdfColors.grey, this.codeDecoration, + this.inlineCodeBackgroundColor, + this.inlineCodeBorderColor, + this.inlineCodeBorderWidth = 1.0, + this.inlineCodePadding = 2.0, + this.inlineClassStyles = const {}, + this.inlineButtonTextColor, + this.inlineButtonBackgroundColor, + this.inlineButtonBorderColor, + this.inlineButtonBorderWidth = 1.0, + this.inlineButtonPadding = 2.0, this.dividerthickness = 1.0, this.dividerColor = PdfColors.grey, this.useDefaultStyles = true, From f5efdf47353ffa6d5f8cfac950ecbae7646f0d82 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 6 Feb 2026 09:36:03 +1100 Subject: [PATCH 3/6] add block style support for eg. callouts --- .../lib/src/browser/html_parser.dart | 34 +++++++++++++++++++ .../lib/src/browser/pdf_builder.dart | 22 ++++++++++-- .../lib/src/htmltagstyles.dart | 34 +++++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/htmltopdfwidgets/lib/src/browser/html_parser.dart b/packages/htmltopdfwidgets/lib/src/browser/html_parser.dart index c57d1c1..0bfd29c 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/html_parser.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/html_parser.dart @@ -108,6 +108,18 @@ class HtmlParser { computedStyle = computedStyle.merge(ruleStyle); } + // 3.1.1 Apply block class-based styles from tagStyle + final classAttr = element.attributes['class']; + if (classAttr != null && classAttr.isNotEmpty && tagStyle.blockClassStyles.isNotEmpty) { + for (final className in classAttr.split(RegExp(r'\s+'))) { + if (className.isEmpty) continue; + final classStyle = tagStyle.blockClassStyles[className]; + if (classStyle != null) { + computedStyle = computedStyle.merge(_convertBlockTagStyleToCSSStyle(classStyle)); + } + } + } + // 3.2 Map HTML attributes to CSS styles (Legacy compatibility) final attributeStyle = _parseAttributesToStyle(element.attributes); computedStyle = computedStyle.merge(attributeStyle); @@ -387,6 +399,28 @@ class HtmlParser { } } + CSSStyle _convertBlockTagStyleToCSSStyle(BlockTagStyle blockStyle) { + return CSSStyle( + color: blockStyle.textColor, + backgroundColor: blockStyle.backgroundColor, + fontSize: blockStyle.fontSize, + fontWeight: blockStyle.fontWeight, + fontStyle: blockStyle.fontStyle, + textAlign: blockStyle.textAlign, + lineHeight: blockStyle.lineHeight, + padding: blockStyle.padding != null + ? EdgeInsets.all(blockStyle.padding!) + : null, + margin: blockStyle.margin != null + ? EdgeInsets.all(blockStyle.margin!) + : null, + border: blockStyle.borderColor != null && blockStyle.borderWidth > 0 + ? Border.all(color: blockStyle.borderColor!, width: blockStyle.borderWidth) + : null, + borderRadius: blockStyle.borderRadius, + ); + } + /// Converts a [TextStyle] to a [CSSStyle]. CSSStyle _convertTextStyleToCSSStyle(TextStyle textStyle) { return CSSStyle( diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index 32f72f0..000b614 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -288,8 +288,8 @@ class PdfBuilder { } final len = getLength(cell); - maxColChars[i] = - (maxColChars[i] ?? 0) < len ? len : maxColChars[i]!; + final current = maxColChars[i] ?? 0; + maxColChars[i] = len > current ? len : current; } } } @@ -848,6 +848,10 @@ class PdfBuilder { /// VERY conservative - only headers and simple blockquotes /// Paragraphs, divs, and other blocks may contain long content that exceeds page height bool _isSmallBlock(RenderNode node, List children) { + if (_hasBlockClassStyle(node)) { + return true; + } + // Only headers are truly guaranteed to be small if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].contains(node.tagName)) { return true; @@ -978,6 +982,17 @@ class PdfBuilder { return buffer.toString(); } + bool _hasBlockClassStyle(RenderNode node) { + if (tagStyle.blockClassStyles.isEmpty) return false; + final classAttr = node.attributes['class']; + if (classAttr == null || classAttr.isEmpty) return false; + for (final className in classAttr.split(RegExp(r'\s+'))) { + if (className.isEmpty) continue; + if (tagStyle.blockClassStyles.containsKey(className)) return true; + } + return false; + } + bool _hasClass(RenderNode node, String className) { final classAttr = node.attributes['class']; if (classAttr == null || classAttr.isEmpty) return false; @@ -1192,6 +1207,9 @@ class PdfBuilder { return pw.BoxDecoration( color: style.backgroundColor, border: style.border, + borderRadius: style.borderRadius != null + ? pw.BorderRadius.circular(style.borderRadius!) + : null, ); } diff --git a/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart b/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart index 6f5abad..097ba44 100644 --- a/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart +++ b/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart @@ -22,6 +22,37 @@ class InlineTagStyle { }); } + +class BlockTagStyle { + final PdfColor? textColor; + final PdfColor? backgroundColor; + final PdfColor? borderColor; + final double borderWidth; + final double? padding; + final double? margin; + final double? borderRadius; + final FontWeight? fontWeight; + final FontStyle? fontStyle; + final double? fontSize; + final TextAlign? textAlign; + final double? lineHeight; + + const BlockTagStyle({ + this.textColor, + this.backgroundColor, + this.borderColor, + this.borderWidth = 1.0, + this.padding, + this.margin, + this.borderRadius, + this.fontWeight, + this.fontStyle, + this.fontSize, + this.textAlign, + this.lineHeight, + }); +} + //apply custom styles to html stylee class HtmlTagStyle { //bold style that will merge with default style @@ -93,6 +124,8 @@ class HtmlTagStyle { final double inlineCodePadding; // Inline class-based styles (applies to span with class) final Map inlineClassStyles; + // Block class-based styles (applies to any element with class) + final Map blockClassStyles; // Inline button text color final PdfColor? inlineButtonTextColor; // Inline button background color @@ -174,6 +207,7 @@ class HtmlTagStyle { this.inlineCodeBorderWidth = 1.0, this.inlineCodePadding = 2.0, this.inlineClassStyles = const {}, + this.blockClassStyles = const {}, this.inlineButtonTextColor, this.inlineButtonBackgroundColor, this.inlineButtonBorderColor, From e6479273418ff332935a1f4fbd6fa4aac510e3d2 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 6 Feb 2026 13:43:34 +1100 Subject: [PATCH 4/6] fix img, table style handling used in "callouts" --- .../lib/src/browser/pdf_builder.dart | 88 +++++++++++++++---- 1 file changed, 72 insertions(+), 16 deletions(-) diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index 000b614..216f5e4 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -265,14 +265,16 @@ class PdfBuilder { } // Determine border style + final border = node.style.border; final borderCollapse = node.style.borderCollapse ?? true; - final borderColor = node.style.border?.top.color ?? PdfColors.grey600; - final borderWidth = node.style.border?.top.width ?? 0.5; + final borderColor = border?.top.color ?? PdfColors.grey600; + final borderWidth = border?.top.width ?? 0.5; // Container prevents spanning. Use Padding/SizedBox for margin. // Detect if table is potentially larger than a page to avoid TooManyPageException // when using FlexColumnWidth (which makes columns narrower and taller) final maxColChars = {}; + final fixedColWidths = {}; for (var child in node.children) { final rows = (child.tagName == 'tr' ? [child] : child.children); for (var row in rows) { @@ -290,6 +292,13 @@ class PdfBuilder { final len = getLength(cell); final current = maxColChars[i] ?? 0; maxColChars[i] = len > current ? len : current; + if (cell.style.width != null) { + final w = cell.style.width!; + final prev = fixedColWidths[i]; + if (prev == null || w > prev) { + fixedColWidths[i] = w; + } + } } } } @@ -310,6 +319,11 @@ class PdfBuilder { columnWidths = {}; } else { for (var entry in maxColChars.entries) { + final fixedWidth = fixedColWidths[entry.key]; + if (fixedWidth != null) { + columnWidths[entry.key] = pw.FixedColumnWidth(fixedWidth); + continue; + } // Give 3x weight to column with massive content (> 1500 chars) final weight = entry.value > 1500 ? 3.0 : 1.0; columnWidths[entry.key] = pw.FlexColumnWidth(weight); @@ -319,12 +333,14 @@ class PdfBuilder { return pw.Padding( padding: node.style.margin ?? const pw.EdgeInsets.symmetric(vertical: 8), child: pw.Table( - border: borderCollapse - ? pw.TableBorder.all(color: borderColor, width: borderWidth) - : pw.TableBorder.symmetric( - inside: pw.BorderSide(color: borderColor, width: borderWidth), - outside: pw.BorderSide(color: borderColor, width: borderWidth), - ), + border: border == null + ? null + : (borderCollapse + ? pw.TableBorder.all(color: borderColor, width: borderWidth) + : pw.TableBorder.symmetric( + inside: pw.BorderSide(color: borderColor, width: borderWidth), + outside: pw.BorderSide(color: borderColor, width: borderWidth), + )), defaultVerticalAlignment: pw.TableCellVerticalAlignment.full, columnWidths: columnWidths.isEmpty ? null : columnWidths, defaultColumnWidth: const pw.FlexColumnWidth(), @@ -338,13 +354,18 @@ class PdfBuilder { final allCellSpans = >[]; final cellStyles = []; final alignments = []; + final cellHasImage = []; int maxChunks = 1; // First pass: collect all content and determine split points for (var child in node.children) { if (child.tagName == 'td' || child.tagName == 'th') { + final hasImage = _containsImage(child); + cellHasImage.add(hasImage); final spans = []; - _collectInlineSpans(child, spans); + if (!hasImage) { + _collectInlineSpans(child, spans); + } allCellSpans.add(spans); cellStyles.add(child); @@ -366,12 +387,14 @@ class PdfBuilder { // Count total chars to see if we split int totalLen = 0; - for (var s in spans) { - if (s is pw.TextSpan) totalLen += (s.text ?? '').length; - } - if (totalLen > 2000) { - final chunks = (totalLen / 1500).ceil(); - if (chunks > maxChunks) maxChunks = chunks; + if (!hasImage) { + for (var s in spans) { + if (s is pw.TextSpan) totalLen += (s.text ?? '').length; + } + if (totalLen > 2000) { + final chunks = (totalLen / 1500).ceil(); + if (chunks > maxChunks) maxChunks = chunks; + } } } } @@ -382,7 +405,9 @@ class PdfBuilder { for (int i = 0; i < allCellSpans.length; i++) { final child = cellStyles[i]; final isHeader = child.tagName == 'th' || isHeaderRow; - final cellContent = _buildCellRichText(allCellSpans[i], isHeader); + final cellContent = cellHasImage[i] + ? await _buildCellContentWithImages(child, isHeader) + : _buildCellRichText(allCellSpans[i], isHeader); cells.add(_wrapCell(child, cellContent, alignments[i], isHeader)); } return [ @@ -409,6 +434,17 @@ class PdfBuilder { final isHeader = child.tagName == 'th' || isHeaderRow; final chunks = splitCellSpans[cellIdx]; + if (cellHasImage[cellIdx]) { + if (chunkIdx == 0) { + final cellContent = + await _buildCellContentWithImages(child, isHeader); + cells.add(_wrapCell(child, cellContent, alignments[cellIdx], isHeader)); + } else { + cells.add(pw.SizedBox()); + } + continue; + } + if (chunkIdx < chunks.length) { final cellContent = _buildCellRichText(chunks[chunkIdx], isHeader); // Only show background/decoration on the first chunk row for headers @@ -426,6 +462,26 @@ class PdfBuilder { return tableRows; } + + bool _containsImage(RenderNode node) { + if (node.tagName == 'img') return true; + for (final child in node.children) { + if (_containsImage(child)) return true; + } + return false; + } + + Future _buildCellContentWithImages( + RenderNode node, bool isHeader) async { + final widgets = await _buildBlockContent(node); + if (widgets.isEmpty) return pw.SizedBox(); + if (widgets.length == 1) return widgets.first; + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: widgets, + ); + } + pw.Widget _wrapCell(RenderNode node, pw.Widget content, pw.Alignment alignment, bool isHeader) { final hasDecoration = node.style.backgroundColor != null || isHeader; From 68698fd0737f433544704eb84568ac2927194c58 Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 6 Feb 2026 16:04:46 +1100 Subject: [PATCH 5/6] fix for diff fonts to have matching baselines this fixes vertical alignment of using 2 different fonts in the same text block. --- .../lib/src/browser/pdf_builder.dart | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index 216f5e4..d6b822b 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -1167,21 +1167,11 @@ class PdfBuilder { final baseStyle = _mapTextStyle(node.style).copyWith(background: null); final bgColor = tagStyle.inlineCodeBackgroundColor ?? node.style.backgroundColor; - final borderColor = tagStyle.inlineCodeBorderColor; - final borderWidth = tagStyle.inlineCodeBorderWidth; - final padding = tagStyle.inlineCodePadding; - - spans.add(pw.WidgetSpan( - baseline: 0, - child: pw.Container( - padding: pw.EdgeInsets.all(padding), - decoration: pw.BoxDecoration( - color: bgColor, - border: borderColor != null && borderWidth > 0 - ? pw.Border.all(color: borderColor, width: borderWidth) - : null, - ), - child: pw.Text(textContent, style: baseStyle), + spans.add(pw.TextSpan( + text: textContent, + style: baseStyle.copyWith( + background: + bgColor != null ? pw.BoxDecoration(color: bgColor) : null, ), )); } From 20f332f0d1cbae26428093406e1612ca99512fff Mon Sep 17 00:00:00 2001 From: Maksim Lin Date: Fri, 6 Feb 2026 16:46:42 +1100 Subject: [PATCH 6/6] remove debug logging --- packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart index d6b822b..a84d2ba 100644 --- a/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart +++ b/packages/htmltopdfwidgets/lib/src/browser/pdf_builder.dart @@ -45,7 +45,6 @@ class PdfBuilder { /// - Block elements containing only inline content (rendered as [pw.RichText]). /// - Generic block elements with mixed content (recursively building children). Future> _buildBlock(RenderNode node) async { - print('Building block: ${node.tagName} children:${node.children.length}'); final widgets = []; if (node.display == Display.none) return widgets;