You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter中实现HtmlWidget渲染内容转PDF或HTML转PDF与Widget效果一致

Flutter中HTML渲染为Widget与PDF输出效果一致的解决方案

需求背景

需要将无JavaScript依赖的简单HTML(如菜谱、路线指引)同时渲染为Flutter Widget和PDF,且两者显示效果完全一致。当前使用flutter_widget_from_html的HtmlWidget组件渲染HTML,能很好支持自定义CSS类样式。

当前HTML渲染实现

HtmlWidget(
  "<html><body><span>a,\nb,c</span></body></html>",
  customStylesBuilder: (element) {
    if (element.classes.contains('a-css-class')) {
      return {
        'color': '#5cb85c',
        'padding': '3px'
      };
    }
    return null;
  }
);

现有PDF转换尝试及问题

使用printing、pdf、htmltopdfwidgets包转换HTML为PDF,代码如下:

import 'package:flutter/material.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
import 'package:htmltopdfwidgets/htmltopdfwidgets.dart' as HTMLtoPDFWidgets;

void main() async {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(),
        body: Center(
            child: IconButton(
                icon: const Icon(Icons.print),
                onPressed: () async {
                  await Printing.layoutPdf(
                      onLayout: (PdfPageFormat format) async {
                    String body =
                        "<html><body><div><span>a,\nb,c</span></body></html>";
                    final pdf = pw.Document();
                    final widgets =
                        await HTMLtoPDFWidgets.HTMLToPdf().convert(body,
                          wrapInParagraph: true // 无效果
                        );
                    pdf.addPage(pw.MultiPage(build: (context) => widgets));
                    return await pdf.save();
                  });
                })));
  }
}

转换效果存在两个核心问题:

  • <span>内的换行被原样保留,不符合行内元素的渲染规则
  • 无法像HtmlWidget那样处理自定义CSS类样式

解决方案

方案一:复用flutter_widget_from_html解析逻辑生成PDF

flutter_widget_from_html的底层依赖fwfh_core包实现HTML解析和样式计算,可以复用这部分逻辑,将解析后的DOM节点和样式直接转换为pdf包的pw.Widget,从根源保证样式一致。

实现步骤

  1. 用fwfh_core的解析器获取带样式的DOM节点树
  2. 递归遍历节点,将其转换为对应的PDF组件
  3. 复用原有的customStylesBuilder逻辑处理自定义样式

示例代码

import 'package:fwfh_core/fwfh_core.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;

Future<pw.Document> generatePdfFromHtml(String rawHtml) async {
  final pdf = pw.Document();
  
  // 复用原样式解析逻辑
  final parser = HtmlParser(
    rawHtml,
    customStylesBuilder: (element) {
      if (element.classes.contains('a-css-class')) {
        return {
          'color': '#5cb85c',
          'padding': '3px',
        };
      }
      return null;
    },
  );
  final rootElement = await parser.parse();

  // 递归转换DOM节点为PDF Widget
  pw.Widget convertToPdfWidget(StyledElement element) {
    if (element is TextElement) {
      // 行内元素的换行替换为空格,和HtmlWidget保持一致
      final processedText = element.text.replaceAll('\n', ' ');
      final styles = element.styles;
      
      return pw.Text(
        processedText,
        style: pw.TextStyle(
          color: PdfColor.fromHex(styles.color ?? '#000000'),
        ),
      );
    } else if (element is StyledElement) {
      final childrenWidgets = element.children.map(convertToPdfWidget).toList();
      final styles = element.styles;
      
      // 处理padding等容器样式
      double paddingValue = 0;
      if (styles.padding != null) {
        paddingValue = double.parse(styles.padding!.replaceAll('px', ''));
      }

      return pw.Container(
        padding: pw.EdgeInsets.all(paddingValue),
        child: pw.Column(children: childrenWidgets),
      );
    }
    return pw.SizedBox.shrink();
  }

  final pdfContent = convertToPdfWidget(rootElement);
  pdf.addPage(pw.MultiPage(build: (context) => [pdfContent]));
  
  return pdf;
}

方案二:优化现有HTML转PDF工具的样式匹配

如果不想深度改造flutter_widget_from_html,可以针对htmltopdfwidgets的问题做预处理:

  1. 处理span内换行:将span标签内的换行替换为空格,符合行内元素渲染规则
  2. 注入自定义CSS:将customStylesBuilder中的样式转换为内嵌CSS,添加到HTML头部

示例代码

void generatePdf() async {
  String rawHtml = "<html><body><span>a,\nb,c</span><div class='a-css-class'>测试样式</div></body></html>";
  
  // 预处理1:替换span内的换行
  rawHtml = rawHtml.replaceAll(RegExp(r'(?<=<span>)(.*?)\n(.*?)(?=</span>)'), r'$1 $2');
  
  // 预处理2:注入自定义CSS
  final customCss = '''
  <style>
    .a-css-class {
      color: #5cb85c;
      padding: 3px;
    }
  </style>
  ''';
  final processedHtml = rawHtml.replaceFirst('<head>', '<head>$customCss');

  // 原PDF转换逻辑
  final pdf = pw.Document();
  final widgets = await HTMLtoPDFWidgets.HTMLToPdf().convert(processedHtml);
  pdf.addPage(pw.MultiPage(build: (context) => widgets));
  await Printing.layoutPdf(onLayout: (_) => pdf.save());
}

方案三:Widget转图片再生成PDF(不推荐)

将HtmlWidget渲染为图片后插入PDF,这种方式简单但存在分辨率低、文本不可选中、分页困难等问题,仅适合内容简短的场景:

import 'package:flutter/rendering.dart';
import 'package:image/image.dart' as img;

Future<pw.Document> generatePdfFromWidget(Widget widget, BuildContext context) async {
  final pdf = pw.Document();
  final key = GlobalKey();

  // 渲染Widget为图片
  await Navigator.of(context).push(MaterialPageRoute(
    builder: (ctx) => RepaintBoundary(key: key, child: widget),
  ));

  final boundary = key.currentContext!.findRenderObject() as RenderRepaintBoundary;
  final image = await boundary.toImage(pixelRatio: 3.0);
  final byteData = await image.toByteData(format: ImageByteFormat.png);
  final pngBytes = byteData!.buffer.asUint8List();
  final imageFile = img.decodePng(pngBytes)!;

  // 添加图片到PDF
  pdf.addPage(pw.Page(
    build: (context) => pw.Image(pw.MemoryImage(img.encodePng(imageFile))),
  ));

  return pdf;
}

内容的提问来源于stack exchange,提问作者bliako

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.14 03:22:07