I have a scanned pdf and want to edit this into Flutter with JSON. So what I am doing:
I have used Adobe apis to first convert the Scanned pdf to searchable pdf using OCR and after that extracted the JSON with Style which has data with styles and other format (see attached json link) .
Backend code to extract the JSON
const params = new ExtractPDFParams({
elementsToExtract: [ExtractElementType.TEXT, ExtractElementType.TABLES],
addCharInfo: true,
});
and see this flutter code
import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:http/http.dart' as http;
import 'package:newflutter/utils/constants.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
class PdfEditorScreen extends StatefulWidget {
const PdfEditorScreen({super.key});
@override
State<PdfEditorScreen> createState() => _PdfEditorScreenState();
}
class _PdfEditorScreenState extends State<PdfEditorScreen> {
Map<String, dynamic>? _jsonData;
bool _loading = false;
String? _error;
final Map<int, TextEditingController> _controllers = {};
late final pw.Font robotoRegular;
late final pw.Font robotoBold;
@override
void initState() {
super.initState();
_loadFonts();
}
Future<void> _loadFonts() async {
robotoRegular =
pw.Font.ttf(await rootBundle.load("assets/fonts/Roboto-Regular.ttf"));
robotoBold =
pw.Font.ttf(await rootBundle.load("assets/fonts/Roboto-Bold.ttf"));
}
// Load JSON from assets
Future<void> _loadFromAssets() async {
setState(() {
_loading = true;
_error = null;
_jsonData = null;
_controllers.clear();
});
try {
final raw = await rootBundle.loadString('assets/structuredDataM.json');
final jsonMap = json.decode(raw) as Map<String, dynamic>;
_initData(jsonMap);
} catch (e) {
setState(() => _error = 'Error loading assets: $e');
} finally {
setState(() => _loading = false);
}
}
// Upload PDF → API → JSON
Future<void> _pickAndUploadPdf() async {
setState(() {
_loading = true;
_error = null;
_jsonData = null;
_controllers.clear();
});
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result == null) {
setState(() => _loading = false);
return;
}
final file = result.files.first;
final uri = Uri.parse("${Constants().baseUrl}/test/convert");
final request = http.MultipartRequest('POST', uri)
..files.add(await http.MultipartFile.fromPath('file', file.path!,
filename: file.name));
final streamed = await request.send();
final response = await http.Response.fromStream(streamed);
if (response.statusCode == 200 || response.statusCode == 201) {
final jsonMap = json.decode(response.body) as Map<String, dynamic>;
_initData(jsonMap);
} else {
setState(
() => _error = 'Something went wrong! Please try after some time ');
}
} catch (e) {
setState(
() => _error = 'Something went wrong! Please try after some time:');
} finally {
setState(() => _loading = false);
}
}
void _initData(Map<String, dynamic> parsed) {
final elements = parsed['elements'] as List;
for (int i = 0; i < elements.length; i++) {
final e = elements[i];
if ((e['Text'] ?? '').toString().isNotEmpty) {
_controllers[i] = TextEditingController(text: e['Text']);
}
}
setState(() => _jsonData = parsed);
}
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
// ---------- UI ----------
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("PDF Editor"),
actions: [
IconButton(
onPressed: _loadFromAssets,
icon: const Icon(Icons.folder_open),
tooltip: 'Load JSON from assets',
),
IconButton(
onPressed: _pickAndUploadPdf,
icon: const Icon(Icons.cloud_upload),
tooltip: 'Upload PDF → Convert',
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(child: Text(_error!))
: _jsonData == null
? const Center(child: Text("Load or upload PDF JSON"))
: _buildEditor(),
floatingActionButton: _jsonData == null
? null
: FloatingActionButton.extended(
onPressed: _makePdfFromEditedContent,
label: const Text("Make PDF"),
icon: const Icon(Icons.picture_as_pdf),
),
);
}
Widget _buildEditor() {
final page = _jsonData!['pages'][0];
final pageWidth = (page['width'] as num).toDouble();
final pageHeight = (page['height'] as num).toDouble();
final elements = _jsonData!['elements'] as List;
// 1) Precompute table BBoxes
final List<Map<String, double>> tableBboxes = [];
for (final e in elements) {
final path = e['Path'] ?? '';
if (path.toString().contains('Table') &&
e['attributes']?['BBox'] != null) {
final bbox = e['attributes']['BBox'] as List;
tableBboxes.add({
'left': (bbox[0] as num).toDouble(),
'bottom': (bbox[1] as num).toDouble(),
'right': (bbox[2] as num).toDouble(),
'top': (bbox[3] as num).toDouble(),
});
}
}
bool _lineInsideAnyTable(List<num> bounds) {
// bounds: [left, bottom, right, top]
final left = (bounds[0] as num).toDouble();
final bottom = (bounds[1] as num).toDouble();
final right = (bounds[2] as num).toDouble();
final top = (bounds[3] as num).toDouble();
// small tolerance for float inaccuracies
const tol = 0.8;
for (final t in tableBboxes) {
if (left >= t['left']! - tol &&
right <= t['right']! + tol &&
bottom >= t['bottom']! - tol &&
top <= t['top']! + tol) {
return true;
}
// Also consider lines that sit exactly on the bbox edges:
// if line overlaps bbox left/right edge or top/bottom edge
final onLeftOrRightEdge = ((left - t['left']!).abs() < tol) ||
((right - t['right']!).abs() < tol);
final onTopOrBottomEdge = ((top - t['top']!).abs() < tol) ||
((bottom - t['bottom']!).abs() < tol);
if ((onLeftOrRightEdge &&
top <= t['top']! + tol &&
bottom >= t['bottom']! - tol) ||
(onTopOrBottomEdge &&
right <= t['right']! + tol &&
left >= t['left']! - tol)) {
return true;
}
}
return false;
}
return InteractiveViewer(
minScale: 0.5,
maxScale: 5.0,
constrained: false,
child: Container(
color: Colors.white,
width: pageWidth,
height: pageHeight,
child: Stack(
children: elements.asMap().entries.map((entry) {
final i = entry.key;
final element = entry.value;
final path = element['Path'] ?? '';
// ---------- TEXT ----------
if (element.containsKey('Text')) {
// prefer Bounds; fallback to CharBounds if available
List boundsList = element['Bounds'] ??
element['CharBounds'] ??
[0, 0, pageWidth, 20];
final left = (boundsList[0] as num).toDouble();
final bottom = (boundsList[1] as num).toDouble();
final right = (boundsList[2] as num).toDouble();
final top = (boundsList[3] as num).toDouble();
final y = pageHeight - top;
final x = left;
final ctl = _controllers[i];
final fontSize = (element['TextSize'] ?? 12).toDouble();
final font = element['Font'];
final fontFamily = font?['family_name'] ?? 'Arial';
final fontWeight = (font?['weight'] ?? 400) >= 600
? FontWeight.bold
: FontWeight.normal;
final fontStyle = (font?['italic'] ?? false)
? FontStyle.italic
: FontStyle.normal;
Color textColor = Colors.black;
if (element['attributes']?['rgb_color'] != null) {
final rgb = element['attributes']['rgb_color'];
textColor = Color.fromRGBO(
((rgb[0] ?? 0) * 255).toInt(),
((rgb[1] ?? 0) * 255).toInt(),
((rgb[2] ?? 0) * 255).toInt(),
1,
);
}
return Positioned(
left: x,
top: y + 20,
width: (right - left) + 30,
height: (top - bottom) + 50,
child: Container(
decoration: element['attributes']?['BBox'] != null
? BoxDecoration(
border: Border.all(color: Colors.black54, width: 1))
: null,
padding: const EdgeInsets.all(2),
child: TextFormField(
controller: ctl,
style: TextStyle(
fontSize: fontSize,
fontFamily: fontFamily,
fontWeight: fontWeight,
fontStyle: fontStyle,
color: textColor,
),
maxLines: null,
expands: true,
decoration: const InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
),
),
),
);
}
// ---------- LINES ----------
// if (path.toString().contains('Line')) {
// final bounds = element['Bounds'] as List;
// // if this line sits inside any table bbox, skip drawing it (prevents double borders)
// // if (_lineInsideAnyTable(bounds)) {
// // return const SizedBox.shrink();
// // }
// final left = (bounds[0] as num).toDouble();
// final bottom = (bounds[1] as num).toDouble();
// final right = (bounds[2] as num).toDouble();
// final top = (bounds[3] as num).toDouble();
// final y = pageHeight - top;
// final x = left;
// return Positioned(
// left: x,
// top: y,
// width: right - left,
// height: (top - bottom) == 0 ? 1 : (top - bottom),
// child: Container(color: Colors.black),
// );
// }
// ---------- TABLES (one rectangle per table) ----------
if (path.toString().contains('Table') &&
element['attributes']?['BBox'] != null) {
final bbox = element['attributes']['BBox'] as List;
final left = (bbox[0] as num).toDouble();
final bottom = (bbox[1] as num).toDouble();
final right = (bbox[2] as num).toDouble();
final top = (bbox[3] as num).toDouble();
final y = pageHeight - top;
final x = left;
return Positioned(
left: x,
top: y,
width: (right - left),
height: top - bottom,
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.black54, width: 1),
),
),
);
}
// ---------- FIGURES ----------
if (path.toString().contains('Figure') &&
element['filePaths'] != null) {
final bounds = element['Bounds'];
final left = (bounds[0] as num).toDouble();
final bottom = (bounds[1] as num).toDouble();
final right = (bounds[2] as num).toDouble();
final top = (bounds[3] as num).toDouble();
final y = pageHeight - top;
final x = left;
final filePath = element['filePaths'][0];
return Positioned(
left: x,
top: y,
width: (right - left),
height: (top - bottom),
child: Image.asset(filePath, fit: BoxFit.contain),
);
}
return const SizedBox.shrink();
}).toList(),
),
),
);
}
// ---------- MAKE PDF FROM EDITED CONTENT ----------
Future<void> _makePdfFromEditedContent() async {
if (_jsonData == null) return;
final pdf = pw.Document();
final page = _jsonData!['pages'][0];
final pageWidth = (page['width'] as num).toDouble();
final pageHeight = (page['height'] as num).toDouble();
final elements = _jsonData!['elements'] as List;
// Precompute table BBoxes (same logic as editor)
final List<Map<String, double>> tableBboxes = [];
for (final e in elements) {
final path = e['Path'] ?? '';
if (path.toString().contains('Table') &&
e['attributes']?['BBox'] != null) {
final bbox = e['attributes']['BBox'] as List;
tableBboxes.add({
'left': (bbox[0] as num).toDouble(),
'bottom': (bbox[1] as num).toDouble(),
'right': (bbox[2] as num).toDouble(),
'top': (bbox[3] as num).toDouble(),
});
}
}
bool _lineInsideAnyTable(List<num> bounds) {
final left = (bounds[0] as num).toDouble();
final bottom = (bounds[1] as num).toDouble();
final right = (bounds[2] as num).toDouble();
final top = (bounds[3] as num).toDouble();
const tol = 0.8;
for (final t in tableBboxes) {
if (left >= t['left']! - tol &&
right <= t['right']! + tol &&
bottom >= t['bottom']! - tol &&
top <= t['top']! + tol) {
return true;
}
final onLeftOrRightEdge = ((left - t['left']!).abs() < tol) ||
((right - t['right']!).abs() < tol);
final onTopOrBottomEdge = ((top - t['top']!).abs() < tol) ||
((bottom - t['bottom']!).abs() < tol);
if ((onLeftOrRightEdge &&
top <= t['top']! + tol &&
bottom >= t['bottom']! - tol) ||
(onTopOrBottomEdge &&
right <= t['right']! + tol &&
left >= t['left']! - tol)) {
return true;
}
}
return false;
}
pdf.addPage(
pw.Page(
pageFormat: PdfPageFormat(pageWidth, pageHeight),
build: (context) {
final widgets = <pw.Widget>[];
for (int i = 0; i < elements.length; i++) {
final element = elements[i];
final path = element['Path'] ?? '';
// ---------- TEXT ----------
if (element.containsKey('Text')) {
final boundsList = element['Bounds'] ??
element['CharBounds'] ??
[0, 0, pageWidth, 20];
final left = (boundsList[0] as num).toDouble();
final bottom = (boundsList[1] as num).toDouble();
final right = (boundsList[2] as num).toDouble();
final top = (boundsList[3] as num).toDouble();
final y = pageHeight - top;
final x = left;
final ctl = _controllers[i];
final text = ctl?.text ?? (element['Text'] ?? '');
final fontSize = (element['TextSize'] ?? 12).toDouble();
final font = element['Font'];
final fontWeight = (font?['weight'] ?? 400) >= 600
? pw.FontWeight.bold
: pw.FontWeight.normal;
final fontStyle = (font?['italic'] ?? false)
? pw.FontStyle.italic
: pw.FontStyle.normal;
PdfColor textColor = PdfColors.black;
final rgb = element['attributes']?['rgb_color'];
if (rgb != null && rgb is List && rgb.length >= 3) {
textColor = PdfColor.fromInt(
(0xFF << 24) |
(((((rgb[0] ?? 0) as num) * 255).toInt()) << 16) |
(((((rgb[1] ?? 0) as num) * 255).toInt()) << 8) |
((((rgb[2] ?? 0) as num) * 255).toInt()),
);
}
final isBold = (font?['weight'] ?? 400) >= 600;
widgets.add(
pw.Positioned(
left: x,
top: y + 25,
child: pw.Container(
width: (right - left) + 25,
height: (top - bottom),
child: pw.Text(
text,
style: pw.TextStyle(
font: isBold ? robotoBold : robotoRegular,
fontSize: fontSize,
fontWeight: fontWeight,
fontStyle: fontStyle,
color: textColor,
),
maxLines: null,
softWrap: true,
),
),
),
);
}
// ---------- LINES ----------
// if (path.toString().contains('Line')) {
// final bounds = element['Bounds'] as List;
// // if (_lineInsideAnyTable(bounds)) {
// // // skip if this line is part of a table bbox (prevents double borders)
// // continue;
// // }
// final left = (bounds[0] as num).toDouble();
// final bottom = (bounds[1] as num).toDouble();
// final right = (bounds[2] as num).toDouble();
// final top = (bounds[3] as num).toDouble();
// final y = pageHeight - top;
// final x = left;
// widgets.add(
// pw.Positioned(
// left: x,
// top: y,
// child: pw.Container(
// width: right - left,
// height: (top - bottom) == 0 ? 1 : (top - bottom),
// color: PdfColors.black,
// ),
// ),
// );
// }
// ---------- TABLES (draw single bbox per table) ----------
if (path.toString().contains('Table') &&
element['attributes']?['BBox'] != null) {
final bbox = element['attributes']['BBox'] as List;
final left = (bbox[0] as num).toDouble();
final bottom = (bbox[1] as num).toDouble();
final right = (bbox[2] as num).toDouble();
final top = (bbox[3] as num).toDouble();
final x = left;
final y = pageHeight - top;
final strokeWidth =
((element['attributes']?['line_width'] ?? 1) as num)
.toDouble();
widgets.add(
pw.Positioned(
left: x,
top: y,
child: pw.Container(
width: (right - left),
height: top - bottom,
decoration: pw.BoxDecoration(
border: pw.Border.all(
color: PdfColors.black,
width: strokeWidth,
),
),
),
),
);
}
// ---------- FIGURES ----------
if (path.toString().contains('Figure') &&
element['filePaths'] != null) {
final bounds = element['Bounds'];
final left = (bounds[0] as num).toDouble();
final bottom = (bounds[1] as num).toDouble();
final right = (bounds[2] as num).toDouble();
final top = (bounds[3] as num).toDouble();
final y = pageHeight - top;
final x = left;
final filePath = element['filePaths'][0];
widgets.add(
pw.Positioned(
left: x,
top: y,
child: pw.Image(
pw.MemoryImage(File(filePath).readAsBytesSync()),
width: (right - left),
height: (top - bottom),
fit: pw.BoxFit.contain),
),
);
}
}
return pw.Stack(children: widgets);
},
),
);
final dir = await getTemporaryDirectory();
final file = File('${dir.path}/edited_output.pdf');
await file.writeAsBytes(await pdf.save());
await OpenFile.open(file.path);
}
}
For output see the attached image, the output borders are not good enough, can someone tell how to optimise this for more accurate borders and text alignment??
I tried many pdf files but the accurate borders, boxes and alignment is not adjusting.
-
@AkshayGupta No, this is only about Flutter PDF border alignment. Please keep comments technical and on-topic.Rakesh Saini– Rakesh Saini2025年09月09日 07:21:37 +00:00Commented Sep 9, 2025 at 7:21
-
"For output see the attached image," there is no attached imagepskink– pskink2025年09月09日 08:23:13 +00:00Commented Sep 9, 2025 at 8:23
-
@pskink Please check i have added screenshot through link.Rakesh Saini– Rakesh Saini2025年09月09日 09:24:26 +00:00Commented Sep 9, 2025 at 9:24
-
Thanks for your comment, is there any free library to render the json or edit the scanned pdf into flutter or web?Rakesh Saini– Rakesh Saini2025年09月09日 11:30:11 +00:00Commented Sep 9, 2025 at 11:30