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 ce79f7f..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; @@ -265,14 +264,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) { @@ -288,8 +289,15 @@ 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; + if (cell.style.width != null) { + final w = cell.style.width!; + final prev = fixedColWidths[i]; + if (prev == null || w > prev) { + fixedColWidths[i] = w; + } + } } } } @@ -310,6 +318,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 +332,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 +353,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 +386,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 +404,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 +433,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 +461,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; @@ -727,6 +782,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. @@ -828,6 +903,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; @@ -949,6 +1028,32 @@ 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 _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; + return classAttr.split(RegExp(r'\s+')).contains(className); + } + void _collectInlineSpans(RenderNode node, List spans) { if (node.display == Display.none) return; @@ -989,6 +1094,89 @@ 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; + spans.add(pw.TextSpan( + text: textContent, + style: baseStyle.copyWith( + background: + bgColor != null ? pw.BoxDecoration(color: bgColor) : null, + ), + )); + } + 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 @@ -1064,6 +1252,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 cbdd8c3..097ba44 100644 --- a/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart +++ b/packages/htmltopdfwidgets/lib/src/htmltagstyles.dart @@ -6,6 +6,53 @@ 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, + }); +} + + +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 @@ -67,6 +114,28 @@ 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; + // Block class-based styles (applies to any element with class) + final Map blockClassStyles; + // 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 +202,17 @@ 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.blockClassStyles = 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,