diff --git a/README.md b/README.md index c3ba53b..473d377 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ Table SHOULD be edited by advanced GUI applications, BUT converted to any other - If you still need 0.0.9, look for an archived asset on [Releases](https://github.com/fanfeilong/exceltk/releases) (do not rely on README version matrices) # Convert Excel to Json - chagne the `-t` option to `json` + change the `-t` option to `json` - `exceltk.exe -t json -xls example.xls ` + - JSON is emitted as an ExcelTk `TableDocument` (`marker=EXCELTK1`) so it can be imported back # Convert Excel to TeX change the `-t` option to `tex` @@ -59,6 +60,20 @@ Table SHOULD be edited by advanced GUI applications, BUT converted to any other - using `-st n` option to split table into multitable - using `-sn` option to adjust number, for example, `1234656` will be split into `1 2 3 4 5 6`, it the table width is too large, this is useful +# Format plugins (export / import) + Output formats are plugins (`md`, `json`, `tex`, `img`). Each plugin can **export** Excel/CSV and **import** back to `.xlsx`. + + - Export: `exceltk -t -xls file.xlsx [-sheet name] [-out prefix]` + - Import: `exceltk -t -import file.ext [-out out.xlsx]` + + Precise payloads use marker `EXCELTK1` (Markdown HTML comment, TeX `%` comment, JSON field, or PNG private chunk `tkXl`). + +# Marked image plugin (`-t img`) + - Export renders a PNG preview and embeds the table payload in a private PNG chunk + - Import only accepts ExcelTk-marked PNGs; unmarked images are rejected + - `exceltk -t img -xls example.xlsx` + - `exceltk -t img -import exampleSheet1.png -out restored.xlsx` + # Download Prebuilt binaries are published through **GitHub Releases** (CI builds self-contained packages for each RID on version tags `v*`). @@ -105,6 +120,8 @@ CI: GitHub Actions (`.github/workflows/ci.yml`) builds on .NET 10 and runs `src/ Architecture note: binary/OpenXML reading is organized as **streaming packages** (emit a complete local format unit as soon as it is recognized) so a renderer can progress while data is still arriving — see [`docs/streaming-packages.md`](docs/streaming-packages.md). +Output formats are **plugins** with export/import — see [`docs/format-plugins.md`](docs/format-plugins.md). + ## Build on MacOS ```bash dotnet publish -r osx-x64 src/Exceltk/Exceltk.csproj -c Release diff --git a/README.zhcn.txt b/README.zhcn.txt index b4c0e1e..c086fa5 100644 --- a/README.zhcn.txt +++ b/README.zhcn.txt @@ -87,11 +87,24 @@ exceltk -t md -a r -xls example.xlsx # 转换到Json - `exceltk.exe -t json -xls example.xls ` + - JSON 使用带 `EXCELTK1` 标记的 TableDocument,可再导入为 xlsx # 转换到TeX - `exceltk.exe -t tex -xls example.xls` - 使用 `-st n` 拆分表格 - 使用 `-sn` 把数字拆分,例如`1234656` 会被拆成`1 2 3 4 5 6`, 如果表太大时有用 + +# 格式插件(导出 / 导入) + 输出格式已插件化:`md` / `json` / `tex` / `img`,均支持 **export** 与 **import**(导回 `.xlsx`)。 + + - 导出:`exceltk -t -xls file.xlsx [-sheet name] [-out prefix]` + - 导入:`exceltk -t -import file.ext [-out out.xlsx]` + +# 带标记的图片插件(`-t img`) + - 导出 PNG 预览,并在私有 chunk `tkXl` 中写入精确表格数据 + - 导入只接受含 ExcelTk 标记的 PNG;无标记图片暂不支持 + - `exceltk -t img -xls example.xlsx` + - `exceltk -t img -import exampleSheet1.png -out restored.xlsx` # 在 Linux 上构建与使用 需要安装 [.NET 10 SDK](https://learn.microsoft.com/dotnet/core/install/linux)(LTS)。仓库根目录 `global.json` 会约束使用 .NET 10 SDK。 diff --git a/docs/format-plugins.md b/docs/format-plugins.md new file mode 100644 index 0000000..8924f71 --- /dev/null +++ b/docs/format-plugins.md @@ -0,0 +1,34 @@ +# Format plugins + +ExcelTk conversion outputs are **format plugins**. Each plugin implements: + +- **Export**: `DataSet` → one or more artifacts (text or binary) +- **Import**: artifact stream → `DataSet` → written as `.xlsx` + +Built-in plugins: + +| `-t` | Extension | Notes | +| --- | --- | --- | +| `md` | `.md` | Pipe / MultiMarkdown tables; embeds `` for precise import | +| `json` | `.json` | `TableDocument` JSON with `marker=EXCELTK1` | +| `tex` | `.tex` | TeX `tabular`; embeds `% EXCELTK1 …` | +| `img` | `.png` | Preview image + private PNG chunk `tkXl`; **unmarked PNGs are rejected** | + +## CLI + +```bash +# export +exceltk -t md -xls book.xlsx +exceltk -t img -xls book.xlsx -sheet Sheet1 + +# import back to Excel +exceltk -t md -import bookSheet1.md -out restored.xlsx +exceltk -t img -import bookSheet1.png -out restored.xlsx +``` + +## Extending + +1. Implement `Exceltk.Format.IFormatPlugin` +2. Register in `FormatRegistry.BuiltInPlugins()` (or call `FormatRegistry.Register` at startup) + +Precise round-trip data uses `TableDocument` (`marker=EXCELTK1`). The image plugin stores `TableDocument.ToMarkedBytes()` inside the `tkXl` chunk so visual pixels are not the source of truth. diff --git a/src/Exceltk/Extension/Extension.cs b/src/Exceltk/Extension/Extension.cs index 9790d85..e6b01ab 100644 --- a/src/Exceltk/Extension/Extension.cs +++ b/src/Exceltk/Extension/Extension.cs @@ -1,59 +1,67 @@ using System; -using System.Text; using System.Collections.Generic; +using System.IO; +using System.Text; #if !OS_WINDOWS using System.Xml; -using System.IO; #endif +using Exceltk.Format; +using Exceltk.Reader; + namespace Exceltk { - public class SimpleTable{ - public string Name{get;set;} - public string Value{get;set;} + public class SimpleTable { + public string Name { get; set; } + public string Value { get; set; } } public static class Extension { - public static IEnumerable ToSimpleTable(this string xls,string ext){ - if(ext=="md"){ - return xls.ToMd(); - }else if(ext=="json"){ - return xls.ToJson(); - } else if (ext == "tex") { - return xls.ToTex(); - } else { - return null; + public static IEnumerable ToSimpleTable(this string xls, string ext) { + IFormatPlugin plugin = FormatRegistry.GetRequired(ext); + DataSet dataSet = WorkbookLoader.Load(xls); + foreach (FormatArtifact artifact in plugin.Export(dataSet, null)) { + if (artifact.IsBinary) { + continue; + } + yield return new SimpleTable { + Name = artifact.Name, + Value = artifact.TextContent + }; } } - public static SimpleTable ToSimpleTable(this string xls,string sheet,string ext){ - if(ext=="md"){ - return xls.ToMd(sheet); - }else if(ext=="json"){ - return xls.ToJson(sheet); - } else if (ext == "tex") { - return xls.ToTex(sheet); - } else { - return null; + public static SimpleTable ToSimpleTable(this string xls, string sheet, string ext) { + IFormatPlugin plugin = FormatRegistry.GetRequired(ext); + DataSet dataSet = WorkbookLoader.Load(xls); + foreach (FormatArtifact artifact in plugin.Export(dataSet, sheet)) { + if (artifact.IsBinary) { + continue; + } + return new SimpleTable { + Name = artifact.Name, + Value = artifact.TextContent + }; } + throw new ArgumentException("Sheet not found: " + sheet); } #if !OS_WINDOWS public static void Close(this XmlReader xmlReader) { xmlReader.Dispose(); } - public static void Close(this Stream stream){ + public static void Close(this Stream stream) { stream.Dispose(); } - public static void Close(this MemoryStream stream){ + public static void Close(this MemoryStream stream) { stream.Dispose(); } - public static void Close(this BinaryReader stream){ + public static void Close(this BinaryReader stream) { stream.Dispose(); } #endif - public static string GetUserName(){ + public static string GetUserName() { #if OS_WINDOWS return Environment.UserName; #else @@ -61,7 +69,7 @@ public static string GetUserName(){ #endif } - public static Encoding DefaultEncoding(){ + public static Encoding DefaultEncoding() { #if OS_WINDOWS return Encoding.Default; #else @@ -70,22 +78,22 @@ public static Encoding DefaultEncoding(){ } public static bool IsHexDigit(this char digit) { - return (('0'<=digit&&digit<='9')|| - ('a'<=digit&&digit<='f')|| - ('A'<=digit&&digit<='F')); + return (('0' <= digit && digit <= '9') || + ('a' <= digit && digit <= 'f') || + ('A' <= digit && digit <= 'F')); } public static int FromHex(this char digit) { - if ('0'<=digit&&digit<='9') { - return (int)(digit-'0'); + if ('0' <= digit && digit <= '9') { + return (int)(digit - '0'); } - if ('a'<=digit&&digit<='f') - return (int)(digit-'a'+10); + if ('a' <= digit && digit <= 'f') + return (int)(digit - 'a' + 10); - if ('A'<=digit&&digit<='F') - return (int)(digit-'A'+10); + if ('A' <= digit && digit <= 'F') + return (int)(digit - 'A' + 10); throw new ArgumentException("digit"); } } -} \ No newline at end of file +} diff --git a/src/Exceltk/Format/FormatArtifact.cs b/src/Exceltk/Format/FormatArtifact.cs new file mode 100644 index 0000000..6d62bc2 --- /dev/null +++ b/src/Exceltk/Format/FormatArtifact.cs @@ -0,0 +1,41 @@ +using System; + +namespace Exceltk.Format { + /// + /// One exported sheet/file produced by a format plugin. + /// + public sealed class FormatArtifact { + public string Name { get; set; } + public string Extension { get; set; } + public bool IsBinary { get; set; } + public string TextContent { get; set; } + public byte[] BinaryContent { get; set; } + + public static FormatArtifact Text(string name, string extension, string content) { + return new FormatArtifact { + Name = name ?? "", + Extension = NormalizeExtension(extension), + IsBinary = false, + TextContent = content ?? "" + }; + } + + public static FormatArtifact Binary(string name, string extension, byte[] content) { + return new FormatArtifact { + Name = name ?? "", + Extension = NormalizeExtension(extension), + IsBinary = true, + BinaryContent = content ?? Array.Empty() + }; + } + + private static string NormalizeExtension(string extension) { + if (string.IsNullOrEmpty(extension)) { + return ""; + } + return extension.StartsWith(".", StringComparison.Ordinal) + ? extension.Substring(1) + : extension; + } + } +} diff --git a/src/Exceltk/Format/FormatRegistry.cs b/src/Exceltk/Format/FormatRegistry.cs new file mode 100644 index 0000000..33330ee --- /dev/null +++ b/src/Exceltk/Format/FormatRegistry.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Exceltk.Format.Plugins; + +namespace Exceltk.Format { + /// + /// Discovers and resolves format plugins by CLI target name. + /// + public static class FormatRegistry { + private static readonly object Gate = new object(); + private static Dictionary _plugins; + + public static IEnumerable All { + get { + EnsureRegistered(); + return _plugins.Values.OrderBy(p => p.Name, StringComparer.Ordinal); + } + } + + public static bool TryGet(string name, out IFormatPlugin plugin) { + EnsureRegistered(); + if (string.IsNullOrEmpty(name)) { + plugin = null; + return false; + } + return _plugins.TryGetValue(name.Trim().ToLowerInvariant(), out plugin); + } + + public static IFormatPlugin GetRequired(string name) { + if (!TryGet(name, out IFormatPlugin plugin)) { + throw new ArgumentException("Unknown format plugin: " + name); + } + return plugin; + } + + public static void Register(IFormatPlugin plugin) { + if (plugin == null) { + throw new ArgumentNullException(nameof(plugin)); + } + EnsureRegistered(); + lock (Gate) { + _plugins[plugin.Name.ToLowerInvariant()] = plugin; + } + } + + private static void EnsureRegistered() { + if (_plugins != null) { + return; + } + lock (Gate) { + if (_plugins != null) { + return; + } + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (IFormatPlugin plugin in BuiltInPlugins()) { + map[plugin.Name.ToLowerInvariant()] = plugin; + } + _plugins = map; + } + } + + private static IEnumerable BuiltInPlugins() { + yield return new MarkdownFormatPlugin(); + yield return new JsonFormatPlugin(); + yield return new TexFormatPlugin(); + yield return new MarkedImageFormatPlugin(); + } + } +} diff --git a/src/Exceltk/Format/IFormatPlugin.cs b/src/Exceltk/Format/IFormatPlugin.cs new file mode 100644 index 0000000..061eaad --- /dev/null +++ b/src/Exceltk/Format/IFormatPlugin.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.IO; + +using Exceltk.Reader; + +namespace Exceltk.Format { + /// + /// Pluggable conversion format. Every format supports export (Excel → format) + /// and import (format → Excel workbook DataSet). + /// + public interface IFormatPlugin { + /// CLI target id, e.g. md / json / tex / img. + string Name { get; } + + /// Default file extension without dot. + string FileExtension { get; } + + /// Human-readable summary for help text. + string Description { get; } + + bool SupportsExport { get; } + bool SupportsImport { get; } + + IEnumerable Export(DataSet dataSet, string sheetFilter); + DataSet Import(Stream input, string sourcePath); + } +} diff --git a/src/Exceltk/Format/Imaging/MarkedPng.cs b/src/Exceltk/Format/Imaging/MarkedPng.cs new file mode 100644 index 0000000..bed6448 --- /dev/null +++ b/src/Exceltk/Format/Imaging/MarkedPng.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Text; + +using ICSharpCode.SharpZipLib.Checksums; +using ICSharpCode.SharpZipLib.Zip.Compression; + +namespace Exceltk.Format.Imaging { + /// + /// Minimal PNG codec that can embed/extract an ExcelTk private chunk (tkXl). + /// Unmarked PNGs are rejected on import. + /// + public static class MarkedPng { + public const string ChunkType = "tkXl"; + private static readonly byte[] Signature = { 137, 80, 78, 71, 13, 10, 26, 10 }; + + public static byte[] EncodeRgba(int width, int height, byte[] rgba, byte[] markerPayload) { + if (width <= 0 || height <= 0) { + throw new ArgumentOutOfRangeException("width/height"); + } + if (rgba == null || rgba.Length < width * height * 4) { + throw new ArgumentException("RGBA buffer too small."); + } + if (markerPayload == null || markerPayload.Length == 0) { + throw new ArgumentException("ExcelTk marker payload is required."); + } + + byte[] raw = BuildRawImage(width, height, rgba); + byte[] compressed = Deflate(raw); + + using (var ms = new MemoryStream()) { + ms.Write(Signature, 0, Signature.Length); + WriteChunk(ms, "IHDR", BuildIhdr(width, height)); + WriteChunk(ms, ChunkType, markerPayload); + WriteChunk(ms, "IDAT", compressed); + WriteChunk(ms, "IEND", Array.Empty()); + return ms.ToArray(); + } + } + + public static bool TryReadMarker(byte[] pngBytes, out byte[] markerPayload) { + markerPayload = null; + if (pngBytes == null || pngBytes.Length < 8) { + return false; + } + for (int i = 0; i < 8; i++) { + if (pngBytes[i] != Signature[i]) { + return false; + } + } + + int offset = 8; + while (offset + 12 <= pngBytes.Length) { + int length = ReadInt32(pngBytes, offset); + if (length < 0 || offset + 12 + length > pngBytes.Length) { + return false; + } + string type = Encoding.ASCII.GetString(pngBytes, offset + 4, 4); + if (type == ChunkType) { + markerPayload = new byte[length]; + Buffer.BlockCopy(pngBytes, offset + 8, markerPayload, 0, length); + return true; + } + if (type == "IEND") { + break; + } + offset += 12 + length; + } + return false; + } + + public static byte[] RequireMarker(byte[] pngBytes) { + byte[] payload; + if (!TryReadMarker(pngBytes, out payload)) { + throw new FormatException( + "Image is not an ExcelTk marked PNG (missing '" + ChunkType + "' chunk). Unmarked images are not supported."); + } + return payload; + } + + private static byte[] BuildRawImage(int width, int height, byte[] rgba) { + var raw = new byte[(width * 4 + 1) * height]; + int dst = 0; + int src = 0; + for (int y = 0; y < height; y++) { + raw[dst++] = 0; // filter None + int rowBytes = width * 4; + Buffer.BlockCopy(rgba, src, raw, dst, rowBytes); + dst += rowBytes; + src += rowBytes; + } + return raw; + } + + private static byte[] BuildIhdr(int width, int height) { + var data = new byte[13]; + WriteInt32(data, 0, width); + WriteInt32(data, 4, height); + data[8] = 8; // bit depth + data[9] = 6; // RGBA + data[10] = 0; + data[11] = 0; + data[12] = 0; + return data; + } + + private static byte[] Deflate(byte[] input) { + var deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, false); + using (var output = new MemoryStream()) { + var buffer = new byte[1024]; + deflater.SetInput(input); + deflater.Finish(); + while (!deflater.IsFinished) { + int count = deflater.Deflate(buffer); + if (count <= 0) { + break; + } + output.Write(buffer, 0, count); + } + return output.ToArray(); + } + } + + private static void WriteChunk(Stream stream, string type, byte[] data) { + byte[] typeBytes = Encoding.ASCII.GetBytes(type); + if (typeBytes.Length != 4) { + throw new ArgumentException("PNG chunk type must be 4 bytes: " + type); + } + WriteInt32(stream, data.Length); + stream.Write(typeBytes, 0, 4); + if (data.Length > 0) { + stream.Write(data, 0, data.Length); + } + + var crc = new Crc32(); + crc.Update(typeBytes); + if (data.Length > 0) { + crc.Update(data); + } + WriteInt32(stream, (int)crc.Value); + } + + private static void WriteInt32(Stream stream, int value) { + stream.WriteByte((byte)((value >> 24) & 0xff)); + stream.WriteByte((byte)((value >> 16) & 0xff)); + stream.WriteByte((byte)((value >> 8) & 0xff)); + stream.WriteByte((byte)(value & 0xff)); + } + + private static void WriteInt32(byte[] buffer, int offset, int value) { + buffer[offset] = (byte)((value >> 24) & 0xff); + buffer[offset + 1] = (byte)((value >> 16) & 0xff); + buffer[offset + 2] = (byte)((value >> 8) & 0xff); + buffer[offset + 3] = (byte)(value & 0xff); + } + + private static int ReadInt32(byte[] buffer, int offset) { + return (buffer[offset] << 24) + | (buffer[offset + 1] << 16) + | (buffer[offset + 2] << 8) + | buffer[offset + 3]; + } + } +} diff --git a/src/Exceltk/Format/Imaging/TableImageRenderer.cs b/src/Exceltk/Format/Imaging/TableImageRenderer.cs new file mode 100644 index 0000000..f31b352 --- /dev/null +++ b/src/Exceltk/Format/Imaging/TableImageRenderer.cs @@ -0,0 +1,245 @@ +using System; +using System.Collections.Generic; +using System.Text; + +using Exceltk.Format; + +namespace Exceltk.Format.Imaging { + /// + /// Renders a simple RGBA preview of a table. Precise data lives in the PNG marker chunk. + /// + public static class TableImageRenderer { + private const int Pad = 8; + private const int CellPadX = 6; + private const int CellPadY = 4; + private const int CharW = 6; + private const int CharH = 8; + + public static byte[] RenderPng(TableDocument document) { + SheetDocument sheet = document.Sheets.Count > 0 + ? document.Sheets[0] + : new SheetDocument { Name = "Sheet1", Rows = new List>() }; + + int rows = Math.Max(1, sheet.Rows.Count); + int cols = 1; + foreach (List row in sheet.Rows) { + if (row != null) { + cols = Math.Max(cols, row.Count); + } + } + + var display = new string[rows, cols]; + var widths = new int[cols]; + for (int c = 0; c < cols; c++) { + widths[c] = 3; + } + for (int r = 0; r < rows; r++) { + List row = r < sheet.Rows.Count ? sheet.Rows[r] : null; + for (int c = 0; c < cols; c++) { + string text = row != null && c < row.Count ? (row[c] ?? "") : ""; + text = Sanitize(text); + display[r, c] = text; + widths[c] = Math.Max(widths[c], Math.Min(40, Math.Max(1, text.Length))); + } + } + + var cellW = new int[cols]; + int tableW = 1; + for (int c = 0; c < cols; c++) { + cellW[c] = widths[c] * CharW + CellPadX * 2; + tableW += cellW[c] + 1; + } + int cellH = CharH + CellPadY * 2; + int tableH = 1 + rows * (cellH + 1); + + int width = tableW + Pad * 2; + int height = tableH + Pad * 2 + CharH + 4; + var rgba = new byte[width * height * 4]; + Fill(rgba, width, height, 245, 248, 250, 255); + + // Title bar with sheet name / marker hint + string title = "ExcelTk · " + (string.IsNullOrEmpty(sheet.Name) ? "Sheet" : sheet.Name) + " · marked"; + DrawText(rgba, width, height, Pad, Pad, title, 40, 60, 80, 255); + + int originX = Pad; + int originY = Pad + CharH + 4; + FillRect(rgba, width, height, originX, originY, tableW, tableH, 30, 40, 50, 255); + + int y = originY + 1; + for (int r = 0; r < rows; r++) { + int x = originX + 1; + for (int c = 0; c < cols; c++) { + byte bgR = r == 0 ? (byte)230 : (byte)255; + byte bgG = r == 0 ? (byte)236 : (byte)255; + byte bgB = r == 0 ? (byte)242 : (byte)255; + FillRect(rgba, width, height, x, y, cellW[c], cellH, bgR, bgG, bgB, 255); + string text = Truncate(display[r, c], widths[c]); + DrawText(rgba, width, height, x + CellPadX, y + CellPadY, text, 20, 28, 36, 255); + x += cellW[c] + 1; + } + y += cellH + 1; + } + + return MarkedPng.EncodeRgba(width, height, rgba, document.ToMarkedBytes()); + } + + private static string Sanitize(string text) { + if (string.IsNullOrEmpty(text)) { + return ""; + } + var sb = new StringBuilder(text.Length); + foreach (char ch in text) { + if (ch == '\r' || ch == '\n' || ch == '\t') { + sb.Append(' '); + } else { + sb.Append(ch); + } + } + return sb.ToString(); + } + + private static string Truncate(string text, int maxChars) { + if (text.Length <= maxChars) { + return text; + } + if (maxChars <= 1) { + return text.Substring(0, maxChars); + } + return text.Substring(0, maxChars - 1) + "…"; + } + + private static void Fill(byte[] rgba, int width, int height, byte r, byte g, byte b, byte a) { + for (int i = 0; i < width * height; i++) { + int o = i * 4; + rgba[o] = r; + rgba[o + 1] = g; + rgba[o + 2] = b; + rgba[o + 3] = a; + } + } + + private static void FillRect(byte[] rgba, int width, int height, int x, int y, int w, int h, byte r, byte g, byte b, byte a) { + for (int yy = y; yy < y + h; yy++) { + if (yy < 0 || yy >= height) { + continue; + } + for (int xx = x; xx < x + w; xx++) { + if (xx < 0 || xx >= width) { + continue; + } + int o = (yy * width + xx) * 4; + rgba[o] = r; + rgba[o + 1] = g; + rgba[o + 2] = b; + rgba[o + 3] = a; + } + } + } + + private static void DrawText(byte[] rgba, int width, int height, int x, int y, string text, byte r, byte g, byte b, byte a) { + int cursor = x; + foreach (char ch in text) { + byte[] glyph = BitmapFont.GetGlyph(ch); + for (int gy = 0; gy < CharH; gy++) { + for (int gx = 0; gx < 5; gx++) { + if (((glyph[gy] >> (4 - gx)) & 1) == 0) { + continue; + } + int px = cursor + gx; + int py = y + gy; + if (px < 0 || py < 0 || px >= width || py >= height) { + continue; + } + int o = (py * width + px) * 4; + rgba[o] = r; + rgba[o + 1] = g; + rgba[o + 2] = b; + rgba[o + 3] = a; + } + } + cursor += CharW; + } + } + } + + /// Tiny 5x8 monospace glyphs for PNG preview text. + internal static class BitmapFont { + private static readonly Dictionary Glyphs = Build(); + + public static byte[] GetGlyph(char ch) { + if (ch >= 'a' && ch <= 'z') { + ch = char.ToUpperInvariant(ch); + } + byte[] glyph; + if (Glyphs.TryGetValue(ch, out glyph)) { + return glyph; + } + return Glyphs['?']; + } + + private static Dictionary Build() { + var map = new Dictionary(); + void Add(char c, params byte[] rows) { map[c] = rows; } + + Add(' ', 0, 0, 0, 0, 0, 0, 0, 0); + Add('.', 0, 0, 0, 0, 0, 0, 0x04, 0); + Add(',', 0, 0, 0, 0, 0, 0x04, 0x08, 0); + Add(':', 0, 0, 0x04, 0, 0x04, 0, 0, 0); + Add('-', 0, 0, 0, 0x1F, 0, 0, 0, 0); + Add('_', 0, 0, 0, 0, 0, 0, 0x1F, 0); + Add('+', 0, 0x04, 0x04, 0x1F, 0x04, 0x04, 0, 0); + Add('/', 0, 0x01, 0x02, 0x04, 0x08, 0x10, 0, 0); + Add('\\', 0, 0x10, 0x08, 0x04, 0x02, 0x01, 0, 0); + Add('(', 0x02, 0x04, 0x08, 0x08, 0x08, 0x04, 0x02, 0); + Add(')', 0x08, 0x04, 0x02, 0x02, 0x02, 0x04, 0x08, 0); + Add('[', 0x0E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x0E, 0); + Add(']', 0x0E, 0x02, 0x02, 0x02, 0x02, 0x02, 0x0E, 0); + Add('\'', 0x04, 0x04, 0, 0, 0, 0, 0, 0); + Add('"', 0x0A, 0x0A, 0, 0, 0, 0, 0, 0); + Add('!', 0x04, 0x04, 0x04, 0x04, 0, 0x04, 0, 0); + Add('?', 0x0E, 0x11, 0x01, 0x02, 0x04, 0, 0x04, 0); + Add('·', 0, 0, 0, 0x04, 0, 0, 0, 0); + Add('…', 0, 0, 0, 0, 0, 0x15, 0, 0); + + Add('0', 0x0E, 0x11, 0x13, 0x15, 0x19, 0x11, 0x0E, 0); + Add('1', 0x04, 0x0C, 0x04, 0x04, 0x04, 0x04, 0x0E, 0); + Add('2', 0x0E, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1F, 0); + Add('3', 0x1F, 0x02, 0x04, 0x02, 0x01, 0x11, 0x0E, 0); + Add('4', 0x02, 0x06, 0x0A, 0x12, 0x1F, 0x02, 0x02, 0); + Add('5', 0x1F, 0x10, 0x1E, 0x01, 0x01, 0x11, 0x0E, 0); + Add('6', 0x06, 0x08, 0x10, 0x1E, 0x11, 0x11, 0x0E, 0); + Add('7', 0x1F, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08, 0); + Add('8', 0x0E, 0x11, 0x11, 0x0E, 0x11, 0x11, 0x0E, 0); + Add('9', 0x0E, 0x11, 0x11, 0x0F, 0x01, 0x02, 0x0C, 0); + + Add('A', 0x0E, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0); + Add('B', 0x1E, 0x11, 0x11, 0x1E, 0x11, 0x11, 0x1E, 0); + Add('C', 0x0E, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0E, 0); + Add('D', 0x1E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x1E, 0); + Add('E', 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x1F, 0); + Add('F', 0x1F, 0x10, 0x10, 0x1E, 0x10, 0x10, 0x10, 0); + Add('G', 0x0E, 0x11, 0x10, 0x17, 0x11, 0x11, 0x0F, 0); + Add('H', 0x11, 0x11, 0x11, 0x1F, 0x11, 0x11, 0x11, 0); + Add('I', 0x0E, 0x04, 0x04, 0x04, 0x04, 0x04, 0x0E, 0); + Add('J', 0x07, 0x02, 0x02, 0x02, 0x02, 0x12, 0x0C, 0); + Add('K', 0x11, 0x12, 0x14, 0x18, 0x14, 0x12, 0x11, 0); + Add('L', 0x10, 0x10, 0x10, 0x10, 0x10, 0x10, 0x1F, 0); + Add('M', 0x11, 0x1B, 0x15, 0x15, 0x11, 0x11, 0x11, 0); + Add('N', 0x11, 0x19, 0x15, 0x13, 0x11, 0x11, 0x11, 0); + Add('O', 0x0E, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0); + Add('P', 0x1E, 0x11, 0x11, 0x1E, 0x10, 0x10, 0x10, 0); + Add('Q', 0x0E, 0x11, 0x11, 0x11, 0x15, 0x12, 0x0D, 0); + Add('R', 0x1E, 0x11, 0x11, 0x1E, 0x14, 0x12, 0x11, 0); + Add('S', 0x0F, 0x10, 0x10, 0x0E, 0x01, 0x01, 0x1E, 0); + Add('T', 0x1F, 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0); + Add('U', 0x11, 0x11, 0x11, 0x11, 0x11, 0x11, 0x0E, 0); + Add('V', 0x11, 0x11, 0x11, 0x11, 0x11, 0x0A, 0x04, 0); + Add('W', 0x11, 0x11, 0x11, 0x15, 0x15, 0x1B, 0x11, 0); + Add('X', 0x11, 0x11, 0x0A, 0x04, 0x0A, 0x11, 0x11, 0); + Add('Y', 0x11, 0x11, 0x0A, 0x04, 0x04, 0x04, 0x04, 0); + Add('Z', 0x1F, 0x01, 0x02, 0x04, 0x08, 0x10, 0x1F, 0); + + return map; + } + } +} diff --git a/src/Exceltk/Format/Plugins/JsonFormatPlugin.cs b/src/Exceltk/Format/Plugins/JsonFormatPlugin.cs new file mode 100644 index 0000000..97e688a --- /dev/null +++ b/src/Exceltk/Format/Plugins/JsonFormatPlugin.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; + +using Exceltk.Reader; + +namespace Exceltk.Format.Plugins { + public sealed class JsonFormatPlugin : IFormatPlugin { + public string Name { get { return "json"; } } + public string FileExtension { get { return "json"; } } + public string Description { get { return "JSON table (export/import)"; } } + public bool SupportsExport { get { return true; } } + public bool SupportsImport { get { return true; } } + + public IEnumerable Export(DataSet dataSet, string sheetFilter) { + // Precise TableDocument JSON so import round-trips cleanly. + TableDocument document = TableDocument.FromDataSet(ShrinkCopy(dataSet, sheetFilter)); + if (!string.IsNullOrEmpty(sheetFilter)) { + foreach (SheetDocument sheet in document.Sheets) { + var one = new TableDocument(); + one.Sheets.Add(sheet); + yield return FormatArtifact.Text(sheet.Name, FileExtension, one.ToJson()); + } + } else if (document.Sheets.Count == 1) { + yield return FormatArtifact.Text(document.Sheets[0].Name, FileExtension, document.ToJson()); + } else { + foreach (SheetDocument sheet in document.Sheets) { + var one = new TableDocument(); + one.Sheets.Add(sheet); + yield return FormatArtifact.Text(sheet.Name, FileExtension, one.ToJson()); + } + } + } + + private static DataSet ShrinkCopy(DataSet dataSet, string sheetFilter) { + var copy = new DataSet(); + foreach (DataTable table in MarkdownFormatPlugin.FilterSheets(dataSet, sheetFilter)) { + DataTable snapshot = MarkdownFormatPlugin.Snapshot(table); + snapshot.Shrink(); + copy.Tables.Add(snapshot); + } + return copy; + } + + public DataSet Import(Stream input, string sourcePath) { + string text; + using (var reader = new StreamReader(input, Encoding.UTF8, true, 1024, true)) { + text = reader.ReadToEnd(); + } + text = text.Trim(); + if (text.Length == 0) { + throw new FormatException("Empty JSON import."); + } + + // Prefer precise ExcelTk document when present. + if (text.Contains("\"marker\"") && text.Contains(TableDocument.Marker)) { + try { + return TableDocument.FromJson(text).ToDataSet(); + } catch { + // fall through to legacy shape + } + } + + if (text.StartsWith("[", StringComparison.Ordinal)) { + return ImportArray(text, sourcePath); + } + return ImportObject(text, sourcePath); + } + + private static DataSet ImportObject(string text, string sourcePath) { + // Legacy exporter uses single-quoted JS-like JSON; normalize quotes. + string normalized = NormalizeLegacyJson(text); + using (JsonDocument doc = JsonDocument.Parse(normalized)) { + JsonElement root = doc.RootElement; + string name = root.TryGetProperty("name", out JsonElement nameEl) + ? nameEl.GetString() + : Path.GetFileNameWithoutExtension(sourcePath ?? "Sheet1"); + var sheet = new SheetDocument { Name = string.IsNullOrEmpty(name) ? "Sheet1" : name }; + if (root.TryGetProperty("rows", out JsonElement rows) && rows.ValueKind == JsonValueKind.Array) { + string[] headers = null; + foreach (JsonElement row in rows.EnumerateArray()) { + if (row.ValueKind != JsonValueKind.Object) { + continue; + } + if (headers == null) { + var keys = new List(); + foreach (JsonProperty prop in row.EnumerateObject()) { + keys.Add(prop.Name); + } + headers = keys.ToArray(); + sheet.Rows.Add(new List(headers)); + } + var values = new List(); + foreach (string key in headers) { + JsonElement val; + values.Add(row.TryGetProperty(key, out val) ? ValueToString(val) : ""); + } + sheet.Rows.Add(values); + } + } + var dataSet = new DataSet(); + dataSet.Tables.Add(sheet.ToDataTable()); + return dataSet; + } + } + + private static DataSet ImportArray(string text, string sourcePath) { + string normalized = NormalizeLegacyJson(text); + using (JsonDocument doc = JsonDocument.Parse(normalized)) { + var dataSet = new DataSet(); + int index = 1; + foreach (JsonElement el in doc.RootElement.EnumerateArray()) { + string name = "Sheet" + index; + if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("name", out JsonElement n)) { + name = n.GetString() ?? name; + } + // Re-serialize single object through object importer. + string one = el.GetRawText(); + DataSet part = ImportObject(one, name); + foreach (DataTable table in part.Tables) { + if (dataSet.Tables.ContainsTable(table.TableName)) { + table.TableName = name + "_" + index; + } + dataSet.Tables.Add(table); + } + index++; + } + if (dataSet.Tables.Count == 0) { + throw new FormatException("No JSON sheets found to import."); + } + return dataSet; + } + } + + private static string NormalizeLegacyJson(string text) { + // Convert {'a':'b'} style into {"a":"b"} for System.Text.Json. + // Only rewrite quotes that are JSON structural, leave escaped content alone via simple pass. + var sb = new StringBuilder(text.Length); + bool inString = false; + char quote = '\0'; + for (int i = 0; i < text.Length; i++) { + char ch = text[i]; + if (!inString) { + if (ch == '\'' || ch == '"') { + inString = true; + quote = ch; + sb.Append('"'); + } else { + sb.Append(ch); + } + continue; + } + + if (ch == '\\' && i + 1 < text.Length) { + char next = text[i + 1]; + if (next == quote) { + sb.Append('\\').Append('"'); + i++; + continue; + } + sb.Append(ch).Append(next); + i++; + continue; + } + if (ch == quote) { + inString = false; + sb.Append('"'); + continue; + } + if (ch == '"' && quote == '\'') { + sb.Append('\\').Append('"'); + continue; + } + sb.Append(ch); + } + return sb.ToString(); + } + + private static string ValueToString(JsonElement val) { + switch (val.ValueKind) { + case JsonValueKind.String: + return UnescapeLegacy(val.GetString() ?? ""); + case JsonValueKind.Number: + case JsonValueKind.True: + case JsonValueKind.False: + return val.ToString(); + case JsonValueKind.Null: + case JsonValueKind.Undefined: + return ""; + default: + return val.GetRawText(); + } + } + + private static string UnescapeLegacy(string value) { + return value + .Replace("\\n", "\n") + .Replace("\\r", "\r") + .Replace("\\t", "\t"); + } + } +} diff --git a/src/Exceltk/Format/Plugins/MarkdownFormatPlugin.cs b/src/Exceltk/Format/Plugins/MarkdownFormatPlugin.cs new file mode 100644 index 0000000..de6dbbe --- /dev/null +++ b/src/Exceltk/Format/Plugins/MarkdownFormatPlugin.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +using Exceltk.Reader; + +namespace Exceltk.Format.Plugins { + public sealed class MarkdownFormatPlugin : IFormatPlugin { + public string Name { get { return "md"; } } + public string FileExtension { get { return "md"; } } + public string Description { get { return "Markdown table (export/import)"; } } + public bool SupportsExport { get { return true; } } + public bool SupportsImport { get { return true; } } + + public IEnumerable Export(DataSet dataSet, string sheetFilter) { + foreach (DataTable table in FilterSheets(dataSet, sheetFilter)) { + DataTable snapshot = Snapshot(table); + snapshot.Shrink(); + var one = new DataSet(); + one.Tables.Add(snapshot); + string marker = EmbedMarkerComment(TableDocument.FromDataSet(one)); + string body = table.ToMd(dataSet); + yield return FormatArtifact.Text(table.TableName, FileExtension, marker + body); + } + } + + internal static DataTable Snapshot(DataTable table) { + DataTable cloned = table.Clone(); + cloned.Merges = table.Merges != null + ? new List(table.Merges) + : new List(); + return cloned; + } + + public DataSet Import(Stream input, string sourcePath) { + string text; + using (var reader = new StreamReader(input, Encoding.UTF8, true, 1024, true)) { + text = reader.ReadToEnd(); + } + + TableDocument marked; + if (TryExtractMarkedDocument(text, out marked)) { + return marked.ToDataSet(); + } + + var sheets = ParseMarkdownTables(text); + if (sheets.Count == 0) { + throw new FormatException("No markdown table found to import."); + } + + string baseName = string.IsNullOrEmpty(sourcePath) + ? "Sheet1" + : Path.GetFileNameWithoutExtension(sourcePath); + var dataSet = new DataSet(); + for (int i = 0; i < sheets.Count; i++) { + string name = sheets.Count == 1 ? baseName : (baseName + "_" + (i + 1)); + sheets[i].Name = name; + dataSet.Tables.Add(sheets[i].ToDataTable()); + } + return dataSet; + } + + /// + /// Optional precise payload comment: <!-- EXCELTK1 {...json...} --> + /// + public static string EmbedMarkerComment(TableDocument document) { + return "" + Environment.NewLine; + } + + private static bool TryExtractMarkedDocument(string text, out TableDocument document) { + document = null; + const string open = ""; + int from = 0; + while (true) { + int start = text.IndexOf(open, from, StringComparison.Ordinal); + if (start < 0) { + return false; + } + int end = text.IndexOf(close, start + open.Length, StringComparison.Ordinal); + if (end < 0) { + return false; + } + string body = text.Substring(start + open.Length, end - start - open.Length).Trim(); + if (body.StartsWith(TableDocument.Marker, StringComparison.Ordinal)) { + string json = body.Substring(TableDocument.Marker.Length).Trim(); + try { + document = TableDocument.FromJson(json); + return true; + } catch { + // keep scanning + } + } + from = end + close.Length; + } + } + + private static List ParseMarkdownTables(string text) { + var sheets = new List(); + var lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var current = new List>(); + foreach (string raw in lines) { + string line = raw.Trim(); + if (line.StartsWith("|", StringComparison.Ordinal) && line.EndsWith("|", StringComparison.Ordinal)) { + if (IsSeparatorRow(line)) { + continue; + } + current.Add(SplitRow(line)); + } else if (current.Count > 0) { + sheets.Add(new SheetDocument { Rows = current }); + current = new List>(); + } + } + if (current.Count > 0) { + sheets.Add(new SheetDocument { Rows = current }); + } + return sheets; + } + + private static bool IsSeparatorRow(string line) { + string inner = line.Trim('|'); + return Regex.IsMatch(inner, @"^[\s|:\-]+$"); + } + + private static List SplitRow(string line) { + string trimmed = line.Trim(); + if (trimmed.StartsWith("|", StringComparison.Ordinal)) { + trimmed = trimmed.Substring(1); + } + if (trimmed.EndsWith("|", StringComparison.Ordinal)) { + trimmed = trimmed.Substring(0, trimmed.Length - 1); + } + var cells = new List(); + var sb = new StringBuilder(); + bool escape = false; + foreach (char ch in trimmed) { + if (escape) { + sb.Append(ch); + escape = false; + continue; + } + if (ch == '\\') { + escape = true; + continue; + } + if (ch == '|') { + cells.Add(CleanCell(sb.ToString())); + sb.Clear(); + continue; + } + sb.Append(ch); + } + cells.Add(CleanCell(sb.ToString())); + return cells; + } + + private static string CleanCell(string value) { + value = (value ?? "").Trim(); + value = value.Replace("
", "\n").Replace("
", "\n"); + if (value.StartsWith("**", StringComparison.Ordinal) && value.EndsWith("**", StringComparison.Ordinal) && value.Length >= 4) { + value = value.Substring(2, value.Length - 4); + } + return value; + } + + internal static IEnumerable FilterSheets(DataSet dataSet, string sheetFilter) { + if (string.IsNullOrEmpty(sheetFilter)) { + foreach (DataTable table in dataSet.Tables) { + yield return table; + } + yield break; + } + if (dataSet.Tables.ContainsTable(sheetFilter)) { + yield return dataSet.Tables[sheetFilter]; + yield break; + } + if (dataSet.Tables.Count == 1) { + yield return dataSet.Tables[0]; + yield break; + } + throw new ArgumentException("Sheet not found: " + sheetFilter); + } + } +} diff --git a/src/Exceltk/Format/Plugins/MarkedImageFormatPlugin.cs b/src/Exceltk/Format/Plugins/MarkedImageFormatPlugin.cs new file mode 100644 index 0000000..a6e1869 --- /dev/null +++ b/src/Exceltk/Format/Plugins/MarkedImageFormatPlugin.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.IO; + +using Exceltk.Format.Imaging; +using Exceltk.Reader; + +namespace Exceltk.Format.Plugins { + /// + /// Export Excel sheets to PNG images that embed an ExcelTk marker chunk (`tkXl`). + /// Import only accepts marked PNGs so round-trip conversion stays precise. + /// + public sealed class MarkedImageFormatPlugin : IFormatPlugin { + public string Name { get { return "img"; } } + public string FileExtension { get { return "png"; } } + public string Description { get { return "Marked PNG image (export/import; unmarked images rejected)"; } } + public bool SupportsExport { get { return true; } } + public bool SupportsImport { get { return true; } } + + public IEnumerable Export(DataSet dataSet, string sheetFilter) { + DataSet shrinked = new DataSet(); + foreach (DataTable table in MarkdownFormatPlugin.FilterSheets(dataSet, sheetFilter)) { + DataTable snapshot = MarkdownFormatPlugin.Snapshot(table); + snapshot.Shrink(); + shrinked.Tables.Add(snapshot); + } + TableDocument full = TableDocument.FromDataSet(shrinked); + foreach (SheetDocument sheet in full.Sheets) { + var one = new TableDocument(); + one.Sheets.Add(sheet); + byte[] png = TableImageRenderer.RenderPng(one); + yield return FormatArtifact.Binary(sheet.Name, FileExtension, png); + } + } + + public DataSet Import(Stream input, string sourcePath) { + byte[] bytes = ReadAll(input); + byte[] marker = MarkedPng.RequireMarker(bytes); + TableDocument document = TableDocument.FromMarkedBytes(marker); + return document.ToDataSet(); + } + + private static byte[] ReadAll(Stream input) { + if (input is MemoryStream ms && ms.TryGetBuffer(out ArraySegment segment)) { + var copy = new byte[segment.Count]; + Buffer.BlockCopy(segment.Array, segment.Offset, copy, 0, segment.Count); + return copy; + } + using (var buffer = new MemoryStream()) { + input.CopyTo(buffer); + return buffer.ToArray(); + } + } + } +} diff --git a/src/Exceltk/Format/Plugins/TexFormatPlugin.cs b/src/Exceltk/Format/Plugins/TexFormatPlugin.cs new file mode 100644 index 0000000..7b75872 --- /dev/null +++ b/src/Exceltk/Format/Plugins/TexFormatPlugin.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +using Exceltk.Reader; + +namespace Exceltk.Format.Plugins { + public sealed class TexFormatPlugin : IFormatPlugin { + public string Name { get { return "tex"; } } + public string FileExtension { get { return "tex"; } } + public string Description { get { return "TeX tabular (export/import)"; } } + public bool SupportsExport { get { return true; } } + public bool SupportsImport { get { return true; } } + + public IEnumerable Export(DataSet dataSet, string sheetFilter) { + foreach (DataTable table in MarkdownFormatPlugin.FilterSheets(dataSet, sheetFilter)) { + DataTable snapshot = MarkdownFormatPlugin.Snapshot(table); + snapshot.Shrink(); + var one = new DataSet(); + one.Tables.Add(snapshot); + string marker = "% " + TableDocument.Marker + " " + TableDocument.FromDataSet(one).ToJson() + + Environment.NewLine; + string body = table.ToTex(dataSet); + yield return FormatArtifact.Text(table.TableName, FileExtension, marker + body); + } + } + + public DataSet Import(Stream input, string sourcePath) { + string text; + using (var reader = new StreamReader(input, Encoding.UTF8, true, 1024, true)) { + text = reader.ReadToEnd(); + } + + TableDocument marked; + if (TryExtractMarkedDocument(text, out marked)) { + return marked.ToDataSet(); + } + + var rows = ParseTabularRows(text); + if (rows.Count == 0) { + throw new FormatException("No TeX tabular rows found to import."); + } + + string name = string.IsNullOrEmpty(sourcePath) + ? "Sheet1" + : Path.GetFileNameWithoutExtension(sourcePath); + var sheet = new SheetDocument { Name = name, Rows = rows }; + var dataSet = new DataSet(); + dataSet.Tables.Add(sheet.ToDataTable()); + return dataSet; + } + + private static bool TryExtractMarkedDocument(string text, out TableDocument document) { + document = null; + using (var reader = new StringReader(text)) { + string line; + while ((line = reader.ReadLine()) != null) { + string trimmed = line.TrimStart(); + if (!trimmed.StartsWith("%", StringComparison.Ordinal)) { + continue; + } + trimmed = trimmed.Substring(1).TrimStart(); + if (!trimmed.StartsWith(TableDocument.Marker, StringComparison.Ordinal)) { + continue; + } + string json = trimmed.Substring(TableDocument.Marker.Length).Trim(); + try { + document = TableDocument.FromJson(json); + return true; + } catch { + // continue + } + } + } + + // Fallback: HTML-style comment if mixed into TeX output. + const string open = ""; + int start = text.IndexOf(open, StringComparison.Ordinal); + if (start >= 0) { + int end = text.IndexOf(close, start + open.Length, StringComparison.Ordinal); + if (end > start) { + string body = text.Substring(start + open.Length, end - start - open.Length).Trim(); + if (body.StartsWith(TableDocument.Marker, StringComparison.Ordinal)) { + try { + document = TableDocument.FromJson(body.Substring(TableDocument.Marker.Length).Trim()); + return true; + } catch { + return false; + } + } + } + } + return false; + } + + private static List> ParseTabularRows(string text) { + var rows = new List>(); + foreach (Match m in Regex.Matches(text, @"^(?.+?)\\\\\s*(?:\\hline)?\s*$", + RegexOptions.Multiline)) { + string rowText = m.Groups["row"].Value.Trim(); + if (rowText.StartsWith("\\", StringComparison.Ordinal) + && !rowText.Contains("&")) { + continue; + } + if (rowText.Contains("begin{") || rowText.Contains("end{") || rowText.Contains("setlength")) { + continue; + } + string[] parts = rowText.Split('&'); + var cells = new List(); + foreach (string part in parts) { + cells.Add(CleanTexCell(part)); + } + if (cells.Count > 0) { + rows.Add(cells); + } + } + return rows; + } + + private static string CleanTexCell(string value) { + value = (value ?? "").Trim(); + value = value.Replace(@"\#", "#") + .Replace(@"\$", "$") + .Replace(@"\%", "%") + .Replace(@"\&", "&") + .Replace(@"\_", "_") + .Replace(@"\{", "{") + .Replace(@"\}", "}") + .Replace(@"~", " "); + value = Regex.Replace(value, @"\\[a-zA-Z]+\s*", ""); + return value.Trim(); + } + } +} diff --git a/src/Exceltk/Format/TableDocument.cs b/src/Exceltk/Format/TableDocument.cs new file mode 100644 index 0000000..41ef5e5 --- /dev/null +++ b/src/Exceltk/Format/TableDocument.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +using Exceltk.Reader; + +namespace Exceltk.Format { + /// + /// Precise, format-neutral workbook payload used for round-trip import/export. + /// Image plugins embed this inside a marked PNG chunk. + /// + public sealed class TableDocument { + public const string Marker = "EXCELTK1"; + public const int Version = 1; + + [JsonPropertyName("marker")] + public string MarkerValue { get; set; } = Marker; + + [JsonPropertyName("version")] + public int VersionValue { get; set; } = Version; + + [JsonPropertyName("sheets")] + public List Sheets { get; set; } = new List(); + + public static TableDocument FromDataSet(DataSet dataSet, string sheetFilter = null) { + var doc = new TableDocument(); + foreach (DataTable table in dataSet.Tables) { + if (!string.IsNullOrEmpty(sheetFilter) + && !string.Equals(table.TableName, sheetFilter, StringComparison.OrdinalIgnoreCase)) { + continue; + } + doc.Sheets.Add(SheetDocument.FromDataTable(table, dataSet)); + } + if (!string.IsNullOrEmpty(sheetFilter) && doc.Sheets.Count == 0) { + throw new ArgumentException("Sheet not found: " + sheetFilter); + } + return doc; + } + + public DataSet ToDataSet() { + var dataSet = new DataSet(); + foreach (SheetDocument sheet in Sheets) { + dataSet.Tables.Add(sheet.ToDataTable()); + } + return dataSet; + } + + public string ToJson() { + return JsonSerializer.Serialize(this, JsonOptions()); + } + + public static TableDocument FromJson(string json) { + if (string.IsNullOrWhiteSpace(json)) { + throw new FormatException("Empty ExcelTk table document."); + } + TableDocument doc = JsonSerializer.Deserialize(json, JsonOptions()); + if (doc == null) { + throw new FormatException("Invalid ExcelTk table document."); + } + if (!string.Equals(doc.MarkerValue, Marker, StringComparison.Ordinal)) { + throw new FormatException("ExcelTk marker missing or unsupported: " + doc.MarkerValue); + } + if (doc.VersionValue != Version) { + throw new FormatException("Unsupported ExcelTk document version: " + doc.VersionValue); + } + if (doc.Sheets == null) { + doc.Sheets = new List(); + } + return doc; + } + + public byte[] ToMarkedBytes() { + byte[] marker = Encoding.ASCII.GetBytes(Marker); + byte[] json = Encoding.UTF8.GetBytes(ToJson()); + var payload = new byte[marker.Length + 1 + json.Length]; + Buffer.BlockCopy(marker, 0, payload, 0, marker.Length); + payload[marker.Length] = 0; + Buffer.BlockCopy(json, 0, payload, marker.Length + 1, json.Length); + return payload; + } + + public static TableDocument FromMarkedBytes(byte[] payload) { + if (payload == null || payload.Length < Marker.Length + 1) { + throw new FormatException("Marked payload too short."); + } + string magic = Encoding.ASCII.GetString(payload, 0, Marker.Length); + if (!string.Equals(magic, Marker, StringComparison.Ordinal)) { + throw new FormatException("ExcelTk image marker not found."); + } + if (payload[Marker.Length] != 0) { + throw new FormatException("ExcelTk image marker is malformed."); + } + string json = Encoding.UTF8.GetString(payload, Marker.Length + 1, payload.Length - Marker.Length - 1); + return FromJson(json); + } + + private static JsonSerializerOptions JsonOptions() { + return new JsonSerializerOptions { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + } + } + + public sealed class SheetDocument { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("rows")] + public List> Rows { get; set; } = new List>(); + + [JsonPropertyName("merges")] + public List Merges { get; set; } = new List(); + + public static SheetDocument FromDataTable(DataTable table, DataSet dataSet) { + var sheet = new SheetDocument { + Name = table.TableName ?? "" + }; + int width = table.Columns.Count; + foreach (DataRow row in table.Rows) { + var cells = new List(width); + object[] items = row.ItemArray ?? Array.Empty(); + for (int c = 0; c < width; c++) { + object cell = c < items.Length ? items[c] : null; + cells.Add(CellText(dataSet, cell)); + } + sheet.Rows.Add(cells); + } + if (table.Merges != null) { + foreach (CellMerge merge in table.Merges) { + sheet.Merges.Add(new MergeDocument { + Row = merge.Row, + Col = merge.Col, + RowSpan = merge.RowSpan, + ColSpan = merge.ColSpan + }); + } + } + return sheet; + } + + public DataTable ToDataTable() { + string name = string.IsNullOrEmpty(Name) ? "Sheet1" : Name; + var table = new DataTable(name); + int width = 0; + foreach (List row in Rows) { + if (row != null) { + width = Math.Max(width, row.Count); + } + } + for (int i = 0; i < width; i++) { + table.Columns.Add(i.ToString(CultureInfo.InvariantCulture), typeof(object)); + } + foreach (List row in Rows) { + var cells = new object[width]; + for (int c = 0; c < width; c++) { + string value = row != null && c < row.Count ? row[c] : ""; + cells[c] = new XlsCell(value ?? ""); + } + table.Rows.Add(cells); + } + if (Merges != null) { + foreach (MergeDocument merge in Merges) { + table.Merges.Add(new CellMerge { + Row = merge.Row, + Col = merge.Col, + RowSpan = merge.RowSpan, + ColSpan = merge.ColSpan + }); + } + } + return table; + } + + private static string CellText(DataSet dataSet, object cell) { + if (cell == null) { + return ""; + } + var xlsCell = cell as XlsCell; + if (xlsCell != null) { + return xlsCell.GetMarkDownText(dataSet) ?? ""; + } + return cell.ToString() ?? ""; + } + } + + public sealed class MergeDocument { + [JsonPropertyName("row")] + public int Row { get; set; } + + [JsonPropertyName("col")] + public int Col { get; set; } + + [JsonPropertyName("rowSpan")] + public int RowSpan { get; set; } + + [JsonPropertyName("colSpan")] + public int ColSpan { get; set; } + } +} diff --git a/src/Exceltk/Format/XlsxWorkbookWriter.cs b/src/Exceltk/Format/XlsxWorkbookWriter.cs new file mode 100644 index 0000000..fa4147f --- /dev/null +++ b/src/Exceltk/Format/XlsxWorkbookWriter.cs @@ -0,0 +1,272 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +using Exceltk.Reader; +using ICSharpCode.SharpZipLib.Zip; + +namespace Exceltk.Format { + /// + /// Minimal OpenXML (.xlsx) writer used by format-plugin import. + /// + public static class XlsxWorkbookWriter { + public static void Write(DataSet dataSet, string path) { + if (dataSet == null) { + throw new ArgumentNullException(nameof(dataSet)); + } + if (string.IsNullOrEmpty(path)) { + throw new ArgumentNullException(nameof(path)); + } + + string dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) { + Directory.CreateDirectory(dir); + } + + using (FileStream fs = File.Create(path)) + using (var zip = new ZipOutputStream(fs)) { + zip.SetLevel(6); + zip.IsStreamOwner = false; + + var sheets = new List(); + foreach (DataTable table in dataSet.Tables) { + sheets.Add(table); + } + if (sheets.Count == 0) { + sheets.Add(new DataTable("Sheet1")); + } + + var shared = new SharedStringTable(); + var sheetXml = new List(); + for (int i = 0; i < sheets.Count; i++) { + sheetXml.Add(BuildSheetXml(sheets[i], shared)); + } + + WriteEntry(zip, "[Content_Types].xml", BuildContentTypes(sheets.Count)); + WriteEntry(zip, "_rels/.rels", BuildRootRels()); + WriteEntry(zip, "xl/workbook.xml", BuildWorkbook(sheets)); + WriteEntry(zip, "xl/_rels/workbook.xml.rels", BuildWorkbookRels(sheets.Count)); + WriteEntry(zip, "xl/styles.xml", BuildStyles()); + WriteEntry(zip, "xl/sharedStrings.xml", shared.ToXml()); + for (int i = 0; i < sheets.Count; i++) { + WriteEntry(zip, "xl/worksheets/sheet" + (i + 1) + ".xml", sheetXml[i]); + } + + zip.Finish(); + } + } + + private static void WriteEntry(ZipOutputStream zip, string name, string content) { + var entry = new ZipEntry(name); + entry.DateTime = DateTime.Now; + zip.PutNextEntry(entry); + byte[] bytes = Encoding.UTF8.GetBytes(content); + zip.Write(bytes, 0, bytes.Length); + zip.CloseEntry(); + } + + private static string BuildContentTypes(int sheetCount) { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + for (int i = 1; i <= sheetCount; i++) { + sb.Append(""); + } + sb.Append(""); + return sb.ToString(); + } + + private static string BuildRootRels() { + return "" + + "" + + "" + + ""; + } + + private static string BuildWorkbook(List sheets) { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + sb.Append(""); + for (int i = 0; i < sheets.Count; i++) { + string name = string.IsNullOrEmpty(sheets[i].TableName) ? ("Sheet" + (i + 1)) : sheets[i].TableName; + sb.Append(""); + } + sb.Append(""); + return sb.ToString(); + } + + private static string BuildWorkbookRels(int sheetCount) { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + for (int i = 1; i <= sheetCount; i++) { + sb.Append(""); + } + int next = sheetCount + 1; + sb.Append(""); + next++; + sb.Append(""); + sb.Append(""); + return sb.ToString(); + } + + private static string BuildStyles() { + return "" + + "" + + "" + + "" + + "" + + "" + + "" + + ""; + } + + private static string BuildSheetXml(DataTable table, SharedStringTable shared) { + int rowCount = table.Rows.Count; + int colCount = Math.Max(1, table.Columns.Count); + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + sb.Append(""); + sb.Append(""); + for (int r = 0; r < rowCount; r++) { + object[] items = table.Rows[r].ItemArray ?? Array.Empty(); + sb.Append(""); + for (int c = 0; c < colCount; c++) { + string text = CellValue(c < items.Length ? items[c] : null); + int index = shared.GetIndex(text); + string refName = ColumnName(c) + (r + 1).ToString(CultureInfo.InvariantCulture); + sb.Append("") + .Append(index) + .Append(""); + } + sb.Append(""); + } + sb.Append(""); + + if (table.Merges != null && table.Merges.Count > 0) { + sb.Append(""); + foreach (CellMerge merge in table.Merges) { + string a = ColumnName(merge.Col) + (merge.Row + 1); + string b = ColumnName(merge.Col + Math.Max(1, merge.ColSpan) - 1) + + (merge.Row + Math.Max(1, merge.RowSpan)); + sb.Append(""); + } + sb.Append(""); + } + + sb.Append(""); + return sb.ToString(); + } + + private static string CellValue(object cell) { + if (cell == null) { + return ""; + } + var xls = cell as XlsCell; + if (xls != null) { + return xls.Value == null ? "" : xls.Value.ToString(); + } + return cell.ToString() ?? ""; + } + + private static string ColumnName(int zeroBased) { + int n = zeroBased + 1; + var sb = new StringBuilder(); + while (n > 0) { + int rem = (n - 1) % 26; + sb.Insert(0, (char)('A' + rem)); + n = (n - 1) / 26; + } + return sb.ToString(); + } + + private static string SanitizeSheetName(string name) { + if (string.IsNullOrEmpty(name)) { + return "Sheet1"; + } + var cleaned = name.Replace("\\", " ").Replace("/", " ").Replace("?", " ") + .Replace("*", " ").Replace("[", " ").Replace("]", " ").Replace(":", " "); + if (cleaned.Length > 31) { + cleaned = cleaned.Substring(0, 31); + } + return string.IsNullOrWhiteSpace(cleaned) ? "Sheet1" : cleaned; + } + + private static string XmlEscape(string value) { + if (string.IsNullOrEmpty(value)) { + return ""; + } + return value + .Replace("&", "&") + .Replace("<", "<") + .Replace(">", ">") + .Replace("\"", """) + .Replace("'", "'"); + } + + private sealed class SharedStringTable { + private readonly Dictionary _index = new Dictionary(StringComparer.Ordinal); + private readonly List _values = new List(); + + public int GetIndex(string value) { + value = value ?? ""; + int existing; + if (_index.TryGetValue(value, out existing)) { + return existing; + } + int idx = _values.Count; + _values.Add(value); + _index[value] = idx; + return idx; + } + + public string ToXml() { + var sb = new StringBuilder(); + sb.Append(""); + sb.Append(""); + foreach (string value in _values) { + sb.Append("") + .Append(XmlEscape(value)) + .Append(""); + } + sb.Append(""); + return sb.ToString(); + } + } + } +} diff --git a/src/Exceltk/Program.cs b/src/Exceltk/Program.cs index cbfc7da..670ba04 100644 --- a/src/Exceltk/Program.cs +++ b/src/Exceltk/Program.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; using System.IO; +using System.Text; +using Exceltk.Format; +using Exceltk.Reader; using Exceltk.Util; namespace Exceltk { @@ -9,7 +12,7 @@ internal class Program { [STAThread] private static void Main(string[] args) { - var cmd=new CommandParser(args); + var cmd = new CommandParser(args); if (cmd["t"] == "tcpstream") { string root = Directory.GetCurrentDirectory(); string xlsx = cmd["xlsx"] ?? cmd["xls"] ?? Path.Combine("test", "test1.xlsx"); @@ -26,27 +29,27 @@ private static void Main(string[] args) { var r = InitConfig(cmd); if (r) { - Xls2MarkDown(cmd); + RunConversion(cmd); } } private static bool InitConfig(CommandParser cmd) { // default Config.DecimalPrecision = 0; - if (cmd["t"]!=null){ - if (cmd["t"] == "md"){ - if (cmd["bhead"]!=null) { + if (cmd["t"] != null) { + if (cmd["t"] == "md") { + if (cmd["bhead"] != null) { Config.BodyHead = true; } else { Config.BodyHead = false; } - Config.PrettyTable = cmd["pretty"]!=null; - Config.MultiMarkdown = cmd["mmd"]!=null; + Config.PrettyTable = cmd["pretty"] != null; + Config.MultiMarkdown = cmd["mmd"] != null; - if (cmd["p"]!=null) { + if (cmd["p"] != null) { int precision = 0; - var ret = Int32.TryParse(cmd["p"],out precision); + var ret = Int32.TryParse(cmd["p"], out precision); if (ret) { if (precision > 10) { Console.WriteLine("presision too larger:" + precision); @@ -59,10 +62,10 @@ private static bool InitConfig(CommandParser cmd) { } } - if(cmd["a"]!=null){ + if (cmd["a"] != null) { var align = cmd["a"]; Config.TableAligin = align; - }else{ + } else { Config.TableAligin = "l"; } } @@ -91,11 +94,10 @@ private static bool InitConfig(CommandParser cmd) { return true; } - private static void Xls2MarkDown(CommandParser cmd) { - int ret=1; + private static void RunConversion(CommandParser cmd) { + int ret = 1; do { - // check target - if (cmd["t"]==null) { + if (cmd["t"] == null) { Console.WriteLine("ERROR:target not found"); break; } @@ -106,70 +108,166 @@ private static void Xls2MarkDown(CommandParser cmd) { Console.WriteLine(" http://files.cnblogs.com/files/math/exceltk0.0.9.7z"); break; } - if(target!="md"&&target!="json"&&target!="tex"){ + + IFormatPlugin plugin; + if (!FormatRegistry.TryGet(target, out plugin)) { Console.WriteLine("ERROR: target not support: {0}", target); + Console.WriteLine("Supported plugins: {0}", string.Join(", ", PluginNames())); break; } - // check input file arg (-xls also accepts .csv) - if (cmd["xls"] == null && cmd["csv"] == null) { - Console.WriteLine("ERROR:xls/csv not found"); + bool importMode = cmd["import"] != null; + if (importMode) { + if (!plugin.SupportsImport) { + Console.WriteLine("ERROR: format plugin '{0}' does not support import", plugin.Name); + break; + } + ret = RunImport(cmd, plugin); break; } - // check input exist - string xls = cmd["xls"] ?? cmd["csv"]; - string sheet = cmd["sheet"]; - string root = Directory.GetCurrentDirectory(); - xls = Path.Combine(root, xls); - if (!File.Exists(xls)) { - Console.WriteLine("ERROR:input file is not exist:{0}", xls); - break; - } - if (!Exceltk.Reader.WorkbookLoader.IsSupportedExtension(xls)) { - Console.WriteLine("ERROR:unsupported file format:{0}", Path.GetExtension(xls)); + if (!plugin.SupportsExport) { + Console.WriteLine("ERROR: format plugin '{0}' does not support export", plugin.Name); break; } + ret = RunExport(cmd, plugin); + } while (false); - // check xls path - string dirName = Path.GetDirectoryName(xls); - string fileName = Path.GetFileNameWithoutExtension(xls); - if(dirName==null||fileName==null){ - Console.WriteLine("ERROR: xls path is valid:{0}",xls); - break; - } + if (ret != 0) { + PrintUsage(); + } + Environment.ExitCode = ret; + } + + private static int RunExport(CommandParser cmd, IFormatPlugin plugin) { + if (cmd["xls"] == null && cmd["csv"] == null) { + Console.WriteLine("ERROR:xls/csv not found"); + return 1; + } + + string xls = cmd["xls"] ?? cmd["csv"]; + string sheet = cmd["sheet"]; + string root = Directory.GetCurrentDirectory(); + if (!Path.IsPathRooted(xls)) { + xls = Path.Combine(root, xls); + } + if (!File.Exists(xls)) { + Console.WriteLine("ERROR:input file is not exist:{0}", xls); + return 1; + } + if (!WorkbookLoader.IsSupportedExtension(xls)) { + Console.WriteLine("ERROR:unsupported file format:{0}", Path.GetExtension(xls)); + return 1; + } + + string dirName = Path.GetDirectoryName(xls); + string fileName = Path.GetFileNameWithoutExtension(xls); + if (dirName == null || fileName == null) { + Console.WriteLine("ERROR: xls path is valid:{0}", xls); + return 1; + } + + string outputBase = cmd["out"] != null + ? (Path.IsPathRooted(cmd["out"]) ? cmd["out"] : Path.Combine(root, cmd["out"])) + : Path.Combine(dirName, fileName); - // output - var output=Path.Combine(dirName, fileName); - if (sheet!=null) { - var table=xls.ToSimpleTable(sheet, target); - string tableFile=output+table.Name+"."+ target; - File.WriteAllText(tableFile, table.Value); - Console.WriteLine("Output File: {0}", tableFile); + DataSet dataSet = WorkbookLoader.Load(xls); + IEnumerable artifacts = plugin.Export(dataSet, sheet); + int written = 0; + foreach (FormatArtifact artifact in artifacts) { + string tableFile = outputBase + artifact.Name + "." + artifact.Extension; + string outDir = Path.GetDirectoryName(tableFile); + if (!string.IsNullOrEmpty(outDir) && !Directory.Exists(outDir)) { + Directory.CreateDirectory(outDir); + } + if (artifact.IsBinary) { + File.WriteAllBytes(tableFile, artifact.BinaryContent); } else { - var tables=xls.ToSimpleTable(target); - foreach (var table in tables) { - string tableFile=output+table.Name+"."+ target; - File.WriteAllText(tableFile, table.Value); - Console.WriteLine("Output File: {0}", tableFile); - } + // UTF-8 without BOM so JSON/md/tex stay machine-friendly. + File.WriteAllText(tableFile, artifact.TextContent, new UTF8Encoding(false)); } - ret=0; - Console.WriteLine("Done!"); + Console.WriteLine("Output File: {0}", tableFile); + written++; + } + if (written == 0) { + Console.WriteLine("ERROR: no sheets exported"); + return 1; + } + Console.WriteLine("Done!"); + return 0; + } + private static int RunImport(CommandParser cmd, IFormatPlugin plugin) { + string input = cmd["import"]; + if (string.IsNullOrEmpty(input) || input == "true") { + // Allow: -import file.ext (CommandParser stores value after flag) + // If flag was bare, try -xls as the import source. + input = cmd["xls"] ?? cmd["csv"]; + } + if (string.IsNullOrEmpty(input) || input == "true") { + Console.WriteLine("ERROR:import input not found (use -import path)"); + return 1; + } - } while (false); + string root = Directory.GetCurrentDirectory(); + if (!Path.IsPathRooted(input)) { + input = Path.Combine(root, input); + } + if (!File.Exists(input)) { + Console.WriteLine("ERROR:import file is not exist:{0}", input); + return 1; + } + + string outPath = cmd["out"]; + if (string.IsNullOrEmpty(outPath) || outPath == "true") { + string dir = Path.GetDirectoryName(input) ?? root; + string name = Path.GetFileNameWithoutExtension(input); + outPath = Path.Combine(dir, name + ".xlsx"); + } else if (!Path.IsPathRooted(outPath)) { + outPath = Path.Combine(root, outPath); + } + if (!outPath.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase)) { + outPath = outPath + ".xlsx"; + } + + try { + using (FileStream stream = File.OpenRead(input)) { + DataSet dataSet = plugin.Import(stream, input); + XlsxWorkbookWriter.Write(dataSet, outPath); + } + } catch (Exception ex) { + Console.WriteLine("ERROR:import failed: {0}", ex.Message); + return 1; + } + + Console.WriteLine("Output File: {0}", outPath); + Console.WriteLine("Done!"); + return 0; + } + + private static IEnumerable PluginNames() { + foreach (IFormatPlugin plugin in FormatRegistry.All) { + yield return plugin.Name; + } + } - if (ret!=0) { - Console.WriteLine(); - Console.WriteLine("Usecase:"); - Console.WriteLine("1. Convert xls/xlsx to markdown: Exceltk -t md -xls xlsfile [-sheet sheetname]"); - Console.WriteLine("2. Convert csv to markdown: Exceltk -t md -xls csvfile (or -csv csvfile)"); - Console.WriteLine("3. Pretty (column-aligned) markdown: Exceltk -t md -pretty -xls file"); - Console.WriteLine("4. MultiMarkdown HTML tables (merged cells): Exceltk -t md -mmd -xls file"); - Console.WriteLine("Note: -t cm (clipboard GUI) was removed after 0.0.9; use 0.0.9 if needed."); - Console.WriteLine("5. TCP streaming package demo: Exceltk -t tcpstream [-xlsx file.xlsx] [-biff file.xls]"); + private static void PrintUsage() { + Console.WriteLine(); + Console.WriteLine("Usecase:"); + Console.WriteLine("1. Export xls/xlsx/csv via format plugin: Exceltk -t md|json|tex|img -xls file [-sheet name]"); + Console.WriteLine("2. Import format file back to Excel: Exceltk -t md|json|tex|img -import file [-out out.xlsx]"); + Console.WriteLine("3. Pretty markdown: Exceltk -t md -pretty -xls file"); + Console.WriteLine("4. MultiMarkdown HTML tables: Exceltk -t md -mmd -xls file"); + Console.WriteLine("5. Marked PNG only: Exceltk -t img -xls file.xlsx / Exceltk -t img -import file.png"); + Console.WriteLine(" (unmarked images are rejected)"); + Console.WriteLine("Note: -t cm (clipboard GUI) was removed after 0.0.9; use 0.0.9 if needed."); + Console.WriteLine("6. TCP streaming package demo: Exceltk -t tcpstream [-xlsx file.xlsx] [-biff file.xls]"); + Console.WriteLine(); + Console.WriteLine("Format plugins:"); + foreach (IFormatPlugin plugin in FormatRegistry.All) { + Console.WriteLine(" -t {0} ({1}) export={2} import={3}", + plugin.Name, plugin.Description, plugin.SupportsExport, plugin.SupportsImport); } } } -} \ No newline at end of file +} diff --git a/src/test.sh b/src/test.sh index f32ddae..e4e9329 100755 --- a/src/test.sh +++ b/src/test.sh @@ -31,3 +31,31 @@ run -t md -xls test/test14_late_columns.xlsx run -t md -xls test/test10_nostyles.xlsx run -t md -xls test/test8_issue8_hyperlinks.xlsx run -t tcpstream -xlsx test/test1.xlsx -biff test/test8.xls + +# Format plugins: export/import round-trip (md/json/img) + reject unmarked PNG +PLUGIN_OUT=$(mktemp -d) +trap 'rm -rf "$PLUGIN_OUT"' EXIT +run -t json -xls test/test1.xlsx -sheet Sheet1 -out "$PLUGIN_OUT/sheet" +JSON_FILE=$(ls "$PLUGIN_OUT"/sheet*.json | head -1) +run -t json -import "$JSON_FILE" -out "$PLUGIN_OUT/from-json.xlsx" +run -t img -xls test/test1.xlsx -sheet Sheet1 -out "$PLUGIN_OUT/img" +PNG_FILE=$(ls "$PLUGIN_OUT"/img*.png | head -1) +run -t img -import "$PNG_FILE" -out "$PLUGIN_OUT/from-img.xlsx" +run -t md -xls test/test1.xlsx -sheet Sheet1 -out "$PLUGIN_OUT/md" +MD_FILE=$(ls "$PLUGIN_OUT"/md*.md | head -1) +run -t md -import "$MD_FILE" -out "$PLUGIN_OUT/from-md.xlsx" +# Unmarked PNG must fail import +python3 - "$PLUGIN_OUT/unmarked.png" <<'PY' +import struct, zlib, sys +sig = b"\x89PNG\r\n\x1a\n" +def chunk(t, d): + return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF) +ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0) +raw = b"\x00" + b"\xff\x00\x00\xff" +open(sys.argv[1], "wb").write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")) +PY +if run -t img -import "$PLUGIN_OUT/unmarked.png" -out "$PLUGIN_OUT/should-fail.xlsx"; then + echo "ERROR: unmarked PNG import should have failed" >&2 + exit 1 +fi +echo "Format plugin round-trip OK"