Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions lib/core/export/image_pdf.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'dart:io' show ZLibCodec;
import 'dart:math' as math;
import 'dart:typed_data';
import 'dart:ui' as ui;

import 'package:flutter_svg/flutter_svg.dart';

/// Paper of a PDF export, in points (1/72 in), portrait.
enum PdfPaper {
a4(595.28, 841.89),
a3(841.89, 1190.55);

const PdfPaper(this.width, this.height);
final double width;
final double height;
}

/// Where a picture of [content] size goes on a [paper] page: the page (landscape
/// when the picture is wider than tall), the picture fitted inside the margin
/// and centred, and the raster density that gives it about 200 dpi.
class PdfPagePlan {
const PdfPagePlan({
required this.pageWidth,
required this.pageHeight,
required this.x,
required this.y,
required this.drawWidth,
required this.drawHeight,
required this.pixelRatio,
});

/// [x] and [y] are the top-left of the picture from the page's top-left.
final double pageWidth;
final double pageHeight;
final double x;
final double y;
final double drawWidth;
final double drawHeight;

/// Raster pixels per unit of [content]; the longest raster side is capped.
final double pixelRatio;

static const double margin = 28;
static const int maxSide = 4096;
static const double dpi = 200;

factory PdfPagePlan.fit(ui.Size content, PdfPaper paper) {
final landscape = content.width > content.height;
final pw = landscape ? paper.height : paper.width;
final ph = landscape ? paper.width : paper.height;
final cw = math.max(1.0, content.width), ch = math.max(1.0, content.height);
final s = math.min((pw - 2 * margin) / cw, (ph - 2 * margin) / ch);
final dw = cw * s, dh = ch * s;
final wanted = s * dpi / 72;
final cap = maxSide / math.max(cw, ch);
return PdfPagePlan(
pageWidth: pw,
pageHeight: ph,
x: (pw - dw) / 2,
y: (ph - dh) / 2,
drawWidth: dw,
drawHeight: dh,
pixelRatio: math.min(wanted, cap),
);
}
}

/// A one-page PDF that shows an RGB image (8 bits a channel, [rgb] of
/// `width * height * 3` bytes) at [plan]. Hand-written: one catalog, one page,
/// one Flate-compressed image and a content stream that places it.
Uint8List buildImagePdf({
required int width,
required int height,
required Uint8List rgb,
required PdfPagePlan plan,
}) {
assert(rgb.length == width * height * 3);
final out = BytesBuilder(copy: false);
final offsets = <int>[];
void ascii(String s) => out.add(latin1.encode(s));
void object(int n, String dict, [Uint8List? stream]) {
offsets.add(out.length);
ascii('$n 0 obj\n');
if (stream == null) {
ascii('$dict\nendobj\n');
} else {
ascii('${dict.replaceFirst('>>', '/Length ${stream.length} >>')}\n'
'stream\n');
out.add(stream);
ascii('\nendstream\nendobj\n');
}
}

String n(double v) => v.toStringAsFixed(2);
ascii('%PDF-1.4\n');
out.add(const [0x25, 0xE2, 0xE3, 0xCF, 0xD3, 0x0A]);
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
object(
3,
'<< /Type /Page /Parent 2 0 R '
'/MediaBox [0 0 ${n(plan.pageWidth)} ${n(plan.pageHeight)}] '
'/Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
object(
4,
'<< /Type /XObject /Subtype /Image /Width $width /Height $height '
'/ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /FlateDecode >>',
Uint8List.fromList(ZLibCodec(level: 6).encode(rgb)));
// PDF's origin is the bottom-left corner of the page.
final bottom = plan.pageHeight - plan.y - plan.drawHeight;
object(
5,
'<< >>',
Uint8List.fromList(latin1.encode('q ${n(plan.drawWidth)} 0 0 '
'${n(plan.drawHeight)} ${n(plan.x)} ${n(bottom)} cm /Im0 Do Q')));

final xref = out.length;
ascii('xref\n0 6\n0000000000 65535 f \n');
for (final o in offsets) {
ascii('${o.toString().padLeft(10, '0')} 00000 n \n');
}
ascii('trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n$xref\n%%EOF\n');
return out.takeBytes();
}

/// Renders an SVG document to a one-page PDF on [paper]: the picture fitted
/// to the page, as a raster of about 200 dpi.
Future<Uint8List> svgToPdf(String svg, {PdfPaper paper = PdfPaper.a4}) async {
final info = await vg.loadPicture(SvgStringLoader(svg), null);
try {
final plan = PdfPagePlan.fit(info.size, paper);
final width = math.max(1, (info.size.width * plan.pixelRatio).ceil());
final height = math.max(1, (info.size.height * plan.pixelRatio).ceil());
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(
recorder, ui.Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()));
// White under the picture: the PDF has no alpha.
canvas.drawColor(const ui.Color(0xFFFFFFFF), ui.BlendMode.src);
canvas.scale(width / info.size.width, height / info.size.height);
canvas.drawPicture(info.picture);
final picture = recorder.endRecording();
try {
final image = await picture.toImage(width, height);
try {
final data = await image.toByteData();
if (data == null) throw StateError('the PDF image could not be read');
final rgba = data.buffer.asUint8List();
final rgb = Uint8List(width * height * 3);
for (var i = 0, j = 0; i < rgba.length; i += 4, j += 3) {
rgb[j] = rgba[i];
rgb[j + 1] = rgba[i + 1];
rgb[j + 2] = rgba[i + 2];
}
return buildImagePdf(
width: width, height: height, rgb: rgb, plan: plan);
} finally {
image.dispose();
}
} finally {
picture.dispose();
}
} finally {
info.picture.dispose();
}
}
163 changes: 163 additions & 0 deletions lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import 'dart:math' show max, min;
import 'dart:ui' show Offset, Rect, Size;

import 'package:querya_desktop/core/erd/erd_note_text.dart';
import 'package:querya_desktop/core/erd/erd_saved_layout.dart'
show ErdGroup, ErdNote;
import 'package:querya_desktop/features/erd/erd_geometry.dart';
import 'package:querya_desktop/features/erd/erd_layout.dart';
import 'package:querya_desktop/core/erd/erd_model.dart';
Expand Down Expand Up @@ -124,6 +126,167 @@ class ErdExport {
return b.toString();
}

// --- DBML (#1285) ---

static final _dbmlPlainType = RegExp(
r'^[A-Za-z_][A-Za-z0-9_]*(\(\s*\d+(\s*,\s*\d+)*\s*\))?(\[\])?$');
static final _dbmlNumber = RegExp(r'^-?\d+(\.\d+)?$');
// 'text' with an optional ::cast after it.
static final _dbmlQuoted = RegExp(r"^'((?:[^']|'')*)'(?:::[^']*)?$");

static String _q(String name) => '"${name.replaceAll('"', r'\"')}"';

/// `"schema"."table"` for `schema.table`, else `"table"`.
static String _dbmlTable(String name) {
final dot = name.indexOf('.');
if (dot <= 0 || dot == name.length - 1) return _q(name);
return '${_q(name.substring(0, dot))}.${_q(name.substring(dot + 1))}';
}

/// A DBML string literal; one with a line break is a `'''` block.
static String _dbmlString(String s) {
final text = s.replaceAll(r'\', r'\\');
if (text.contains('\n')) {
return "'''\n${text.replaceAll("'''", r"\'''")}\n'''";
}
return "'${text.replaceAll("'", r"\'")}'";
}

/// A column's type: bare when DBML reads it as one word (`varchar(255)`),
/// quoted otherwise (`"timestamp with time zone"`).
static String _dbmlType(String type) {
final t = type.trim();
if (t.isEmpty) return '"unknown"';
return _dbmlPlainType.hasMatch(t) ? t : _q(t);
}

/// A default as DBML writes it: a number, a boolean or null as is, a string
/// literal as a string (its `::cast` dropped), anything else as an
/// expression in backticks.
static String _dbmlDefault(String value) {
final v = value.trim();
final lower = v.toLowerCase();
if (_dbmlNumber.hasMatch(v) ||
lower == 'true' ||
lower == 'false' ||
lower == 'null') {
return lower == 'null' || lower == 'true' || lower == 'false'
? lower
: v;
}
final m = _dbmlQuoted.firstMatch(v);
if (m != null) {
return "'${m.group(1)!.replaceAll("''", r"\'")}'";
}
return '`${v.replaceAll('`', "'")}`';
}

/// The DBML of [schema] (the shown tables), for dbdiagram and dbdocs:
/// tables with `pk`, `unique`, `not null`, `default`, `increment` and
/// notes, enums, refs, table groups and sticky notes. [headerColors] are
/// `#rrggbb` per table.
///
/// A foreign key on its own unique column is `-` (one-to-one), any other
/// `>` (many-to-one). Composite primary keys go in an `indexes` block.
static String toDbml(
ErdSchema schema, {
List<ErdGroup> groups = const [],
List<ErdNote> notes = const [],
Map<String, String> headerColors = const {},
}) {
final names = {for (final t in schema.tables) t.name};
final b = StringBuffer('// Exported from Querya\n');

// Enums by the name of their type; a type that is no plain word (MySQL's
// enum('a','b')) is named after its column.
final enums = <String, List<String>>{};
final enumOf = <String, String>{};
for (final t in schema.tables) {
for (final c in t.columns) {
if (c.enumValues.isEmpty) continue;
final type = c.type.trim();
final name = _dbmlPlainType.hasMatch(type) && !type.contains('(')
? type
: '${t.name.replaceAll('.', '_')}_${c.name}';
enums.putIfAbsent(name, () => c.enumValues);
enumOf['${t.name}\u0000${c.name}'] = name;
}
}
for (final e in enums.entries) {
b.writeln('\nEnum ${_dbmlTable(e.key)} {');
for (final v in e.value) {
b.writeln(' ${_q(v)}');
}
b.writeln('}');
}

for (final t in schema.tables) {
final settings = [
if (headerColors[t.name] case final c?) 'headercolor: $c',
if (t.comment case final note?) 'note: ${_dbmlString(note)}',
];
b.writeln('\nTable ${_dbmlTable(t.name)}'
'${settings.isEmpty ? '' : ' [${settings.join(', ')}]'} {');
final pks = [for (final c in t.columns) if (c.isPrimaryKey) c];
for (final c in t.columns) {
final type = enumOf['${t.name}\u0000${c.name}'] != null
? _dbmlTable(enumOf['${t.name}\u0000${c.name}']!)
: _dbmlType(c.type);
final attrs = [
if (c.isPrimaryKey && pks.length == 1) 'pk',
if (c.isIdentity) 'increment',
// Spelled out for the columns of a composite key too, which carry
// no `pk` of their own.
if (!c.isNullable && !(c.isPrimaryKey && pks.length == 1))
'not null',
if (c.isUnique && !c.isPrimaryKey) 'unique',
if (c.defaultValue != null && !c.isIdentity)
'default: ${_dbmlDefault(c.defaultValue!)}',
if (c.comment case final note?) 'note: ${_dbmlString(note)}',
];
b.writeln(' ${_q(c.name)} $type'
'${attrs.isEmpty ? '' : ' [${attrs.join(', ')}]'}');
}
if (pks.length > 1) {
b
..writeln('')
..writeln(' indexes {')
..writeln(' (${pks.map((c) => _q(c.name)).join(', ')}) [pk]')
..writeln(' }');
}
b.writeln('}');
}

var anyRef = false;
for (final r in schema.relations) {
if (!names.contains(r.fromTable) || !names.contains(r.toTable)) continue;
if (!anyRef) b.writeln('');
anyRef = true;
b.writeln('Ref: ${_dbmlTable(r.fromTable)}.${_q(r.fromColumn)} '
'${r.oneToOne ? '-' : '>'} '
'${_dbmlTable(r.toTable)}.${_q(r.toColumn)}');
}

for (final g in groups) {
final members = [for (final t in g.tables) if (names.contains(t)) t];
if (members.isEmpty) continue;
final settings = [
if (g.note case final note?) 'note: ${_dbmlString(note)}',
];
b.writeln('\nTableGroup ${_q(g.name)}'
'${settings.isEmpty ? '' : ' [${settings.join(', ')}]'} {');
for (final t in members) {
b.writeln(' ${_dbmlTable(t)}');
}
b.writeln('}');
}

for (final n in notes) {
b.writeln('\nNote ${_q('note_${n.id}')} {\n ${_dbmlString(n.text)}\n}');
}
return b.toString();
}

/// Pixel ratio that keeps the longest side of a PNG of [size] within
/// [maxSide]. Capped at 2, the on-screen density. Below 1 the image is
/// rendered at reduced resolution.
Expand Down
Loading
Loading