From 84c74e26ce1ca080631742dc6668ba916c934ab4 Mon Sep 17 00:00:00 2001 From: TheIrvin Date: Fri, 2 Oct 2026 16:53:05 -0500 Subject: [PATCH] Complete local Markdown conversion flow --- .github/workflows/ci.yml | 19 +++ .gitignore | 2 + MarkItDownWeb.slnx | 3 + README.md | 90 +++++++++++ azure/deploy.sh | 44 ----- .../MarkItDownWeb.Infrastructure.csproj | 4 +- .../Services/MarkItDownService.cs | 17 +- src/Web/Controllers/ConversionController.cs | 28 +++- src/Web/MarkItDownWeb.Web.csproj | 2 +- src/Web/Views/Conversion/Index.cshtml | 65 +++++--- src/Web/appsettings.json | 2 +- src/Web/wwwroot/css/site.css | 90 ++++++++++- src/Web/wwwroot/js/conversion.js | 151 +++++++++++++----- .../FileSignatureValidatorTests.cs | 68 ++++++++ .../MarkItDownWeb.Tests.csproj | 22 +++ 15 files changed, 489 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 README.md delete mode 100644 azure/deploy.sh create mode 100644 tests/MarkItDownWeb.Tests/FileSignatureValidatorTests.cs create mode 100644 tests/MarkItDownWeb.Tests/MarkItDownWeb.Tests.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4b53fa3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.0.x + - run: dotnet test MarkItDownWeb.slnx --configuration Release diff --git a/.gitignore b/.gitignore index f14a154..f3970af 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ Storage/ .vs/ *.user *.db +*.db-* publish/ +.venv/ diff --git a/MarkItDownWeb.slnx b/MarkItDownWeb.slnx index d0e6c14..9a37ff9 100644 --- a/MarkItDownWeb.slnx +++ b/MarkItDownWeb.slnx @@ -12,4 +12,7 @@ + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..5547eef --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# FileToMarkdown + +Aplicación web local para convertir documentos a Markdown. Selecciona un archivo, revisa el resultado en el navegador y descarga un `.md` con el nombre del documento original. + +## Funciones + +- Interfaz web responsive con vista previa, copia y descarga del Markdown. +- Historial local de conversiones con búsqueda y reapertura de resultados. +- Conversión de PDF, DOCX, PPTX, XLSX, HTML y CSV mediante [Microsoft MarkItDown](https://github.com/microsoft/markitdown). +- Base de datos SQLite y archivos de resultado guardados en el equipo. +- Validación de extensión, tamaño máximo de 100 MiB y firma de archivos PDF/Office. + +La aplicación se ejecuta en tu equipo. Los archivos se envían al proceso web local y a MarkItDown; esta configuración no proporciona autenticación, así que mantenla accesible solo desde tu propio equipo y no la expongas a una red pública. + +## Requisitos + +- [.NET SDK 10](https://dotnet.microsoft.com/download/dotnet/10.0) +- Python y `pip` + +## Ejecutar en local + +Desde la raíz del repositorio: + +```bash +python -m venv .venv +``` + +Activa el entorno virtual y luego instala MarkItDown: + +```bash +# Windows PowerShell +.venv\Scripts\Activate.ps1 +python -m pip install --upgrade pip +python -m pip install 'markitdown[all]' +``` + +```bash +# Linux / macOS +source .venv/bin/activate +python -m pip install --upgrade pip +python -m pip install 'markitdown[all]' +``` + +Inicia la interfaz: + +```bash +dotnet run --project src/Web/MarkItDownWeb.Web.csproj --launch-profile http +``` + +Abre [http://localhost:5241](http://localhost:5241). La ruta del ejecutable de Python se puede configurar con `MarkItDown__PythonPath`; por defecto se usa `python` del entorno activo. Por ejemplo, en PowerShell: + +```powershell +$env:MarkItDown__PythonPath = (Get-Command python).Source +dotnet run --project src/Web/MarkItDownWeb.Web.csproj --launch-profile http +``` + +## Comprobar cambios + +```bash +dotnet test MarkItDownWeb.slnx --configuration Release +``` + +GitHub Actions ejecuta la misma comprobación al abrir o actualizar un pull request y al hacer push a `master`. + +## Uso + +1. Elige un PDF, DOCX, PPTX, XLSX, HTML o CSV de hasta 100 MiB. +2. Pulsa **Convertir a Markdown** y revisa el contenido generado. +3. Cópialo o descárgalo como `.md`. +4. Abre una conversión anterior desde el historial. Los datos permanecen en el equipo. + +## Datos locales + +La base de datos SQLite se crea automáticamente. Los archivos temporales se eliminan al terminar la conversión; el historial conserva los resultados Markdown y los metadatos. Las carpetas se pueden cambiar desde `src/Web/appsettings.json` (`Storage:UploadsFolder`, `Storage:MarkdownFolder` y `ConnectionStrings:DefaultConnection`). No se conserva el documento original después de convertirlo. + +## Estructura + +```text +src/ + Domain/ Entidades y tipos de conversión + Application/ Flujo de conversión e interfaces + Infrastructure/ MarkItDown, almacenamiento y SQLite + Web/ Interfaz ASP.NET Core MVC +``` + +## Limitaciones conocidas + +- La calidad del Markdown depende del formato y del contenido del documento original. +- MarkItDown puede necesitar dependencias adicionales para ciertos formatos; consulta su documentación si la instalación completa no está disponible en tu plataforma. +- Esta versión está pensada para uso local individual; no incluye cuentas, aislamiento entre usuarios ni almacenamiento en la nube. diff --git a/azure/deploy.sh b/azure/deploy.sh deleted file mode 100644 index 65e47e7..0000000 --- a/azure/deploy.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# === Azure Deployment Script for MarkItDownWeb === -# Adjust variables below before running. - -RESOURCE_GROUP="rg-markitdownweb" -LOCATION="eastus" -APP_SERVICE_PLAN="plan-markitdownweb" -WEB_APP_NAME="markitdownweb-app" -SQL_SERVER="sql-markitdownweb" -SQL_DB="MarkItDownWeb" -SQL_ADMIN="sqladmin" -SQL_PASSWORD="YourStrongPassword123!" - -az login - -# Resource group -az group create --name "$RESOURCE_GROUP" --location "$LOCATION" - -# App Service Plan + Web App -az appservice plan create --name "$APP_SERVICE_PLAN" --resource-group "$RESOURCE_GROUP" --sku B1 --is-linux -az webapp create --resource-group "$RESOURCE_GROUP" --plan "$APP_SERVICE_PLAN" \ - --name "$WEB_APP_NAME" --runtime "DOTNETCORE:9.0" - -# SQL Server + Database -az sql server create --name "$SQL_SERVER" --resource-group "$RESOURCE_GROUP" \ - --admin-user "$SQL_ADMIN" --admin-password "$SQL_PASSWORD" -az sql db create --resource-group "$RESOURCE_GROUP" --server "$SQL_SERVER" \ - --name "$SQL_DB" --service-objective S0 - -# Connection string -CS="Server=tcp:$SQL_SERVER.database.windows.net,1433;Database=$SQL_DB;User ID=$SQL_ADMIN;Password=$SQL_PASSWORD;Trusted_Connection=False;Encrypt=True;" -az webapp config connection-string set --resource-group "$RESOURCE_GROUP" \ - --name "$WEB_APP_NAME" --settings DefaultConnection="$CS" --connection-string-type SQLAzure - -# App settings -az webapp config appsettings set --resource-group "$RESOURCE_GROUP" --name "$WEB_APP_NAME" \ - --settings MarkItDown__PythonPath="/usr/bin/python3" Storage__UploadsFolder="/home/site/wwwroot/Uploads" - -# Publish and deploy -dotnet publish src/Web -c Release -o ./publish -cd publish && zip -r ../publish.zip . && cd .. -az webapp deploy --resource-group "$RESOURCE_GROUP" --name "$WEB_APP_NAME" --src-path publish.zip --type zip diff --git a/src/Infrastructure/MarkItDownWeb.Infrastructure.csproj b/src/Infrastructure/MarkItDownWeb.Infrastructure.csproj index 52d741c..b122e7f 100644 --- a/src/Infrastructure/MarkItDownWeb.Infrastructure.csproj +++ b/src/Infrastructure/MarkItDownWeb.Infrastructure.csproj @@ -6,11 +6,11 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/src/Infrastructure/Services/MarkItDownService.cs b/src/Infrastructure/Services/MarkItDownService.cs index 2a58b2d..393ca1b 100644 --- a/src/Infrastructure/Services/MarkItDownService.cs +++ b/src/Infrastructure/Services/MarkItDownService.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Text; using MarkItDownWeb.Application.Interfaces; +using MarkItDownWeb.Infrastructure.Security; using Microsoft.Extensions.Configuration; namespace MarkItDownWeb.Infrastructure.Services; @@ -19,6 +20,10 @@ public async Task ConvertFileAsync(string absoluteFilePath, Cancellation if (!File.Exists(absoluteFilePath)) throw new FileNotFoundException("File not found", absoluteFilePath); + var extension = Path.GetExtension(absoluteFilePath); + if (!FileSignatureValidator.IsValid(absoluteFilePath, extension)) + throw new InvalidOperationException("The file contents do not match its extension."); + var psi = new ProcessStartInfo { FileName = _pythonPath, @@ -41,7 +46,17 @@ public async Task ConvertFileAsync(string absoluteFilePath, Cancellation process.BeginOutputReadLine(); process.BeginErrorReadLine(); - await process.WaitForExitAsync(ct); + try + { + await process.WaitForExitAsync(ct); + } + catch (OperationCanceledException) + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + + throw; + } if (process.ExitCode != 0) throw new InvalidOperationException($"MarkItDown failed: {stderr}"); diff --git a/src/Web/Controllers/ConversionController.cs b/src/Web/Controllers/ConversionController.cs index a9b7176..a25a66f 100644 --- a/src/Web/Controllers/ConversionController.cs +++ b/src/Web/Controllers/ConversionController.cs @@ -15,16 +15,23 @@ public ConversionController(ConversionService conversionService) public IActionResult Index() => View(new ConversionViewModel()); [HttpPost] - [RequestSizeLimit(100_000_000)] - public async Task Convert(IFormFile file, CancellationToken ct) + [RequestSizeLimit(105_906_176)] + public async Task Convert(IFormFile? file, CancellationToken ct) { if (file is null || file.Length == 0) return BadRequest(new { error = "No file was received." }); - await using var stream = file.OpenReadStream(); - var result = await _conversionService.ConvertAsync(stream, file.FileName, file.Length, ct); + try + { + await using var stream = file.OpenReadStream(); + var result = await _conversionService.ConvertAsync(stream, file.FileName, file.Length, ct); - return Json(result); + return Json(result); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { error = ex.Message }); + } } [HttpGet] @@ -37,8 +44,15 @@ public async Task History(string? search, DateTime? date, Cancell [HttpGet] public async Task Item(Guid id, CancellationToken ct) { - var result = await _conversionService.GetHistoryItemAsync(id, ct); - return Json(result); + try + { + var result = await _conversionService.GetHistoryItemAsync(id, ct); + return Json(result); + } + catch (KeyNotFoundException) + { + return NotFound(new { error = "Conversion not found." }); + } } [HttpPost] diff --git a/src/Web/MarkItDownWeb.Web.csproj b/src/Web/MarkItDownWeb.Web.csproj index bd94e24..88a8df8 100644 --- a/src/Web/MarkItDownWeb.Web.csproj +++ b/src/Web/MarkItDownWeb.Web.csproj @@ -6,7 +6,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/Web/Views/Conversion/Index.cshtml b/src/Web/Views/Conversion/Index.cshtml index 2e95f01..54e1731 100644 --- a/src/Web/Views/Conversion/Index.cshtml +++ b/src/Web/Views/Conversion/Index.cshtml @@ -3,39 +3,58 @@ ViewData["Title"] = "Convertir archivos a Markdown"; } -
-
-
-

Convertir archivos a Markdown

+
+
+

CONVERSIÓN LOCAL

+

Convierte tus documentos a Markdown

+

Sube un archivo, revisa el resultado y descarga tu .md.

+
-
- - - +
+
+
+ + +
PDF, Word, PowerPoint, Excel, HTML o CSV · máximo 100 MB
+
+ + +
-
-
-
Markdown generado
+
+
- - - +

RESULTADO

+

Markdown generado

+
+
+ + +
-

+                

             
-
+
-
-
Historial
- -
    -
    +
    @section Scripts { - + } diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index 6d710c5..65b9e43 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -3,7 +3,7 @@ "DefaultConnection": "Data Source=MarkItDownWeb.db" }, "MarkItDown": { - "PythonPath": "C:\\Python314\\python.exe" + "PythonPath": "python" }, "Storage": { "UploadsFolder": "Uploads", diff --git a/src/Web/wwwroot/css/site.css b/src/Web/wwwroot/css/site.css index 819f612..68ed224 100644 --- a/src/Web/wwwroot/css/site.css +++ b/src/Web/wwwroot/css/site.css @@ -19,6 +19,94 @@ html { body { margin-bottom: 60px; + background: #f5f7fa; + color: #17212f; +} + +.navbar { + background: #fff; +} + +.conversion-shell { + max-width: 1440px; + margin: 2.25rem auto; +} + +.page-heading h1 { + max-width: 760px; + font-size: clamp(1.8rem, 3vw, 2.5rem); + font-weight: 700; + letter-spacing: -0.035em; +} + +.eyebrow { + color: #64748b; + font-size: .7rem; + font-weight: 700; + letter-spacing: .12em; +} + +.app-card { + height: 100%; + border: 1px solid #e4e9f0; + border-radius: 14px; + box-shadow: 0 8px 28px rgb(15 23 42 / 4%); +} + +.status-text { + font-size: .9rem; +} + +.markdown-output { + min-height: 360px; + max-height: 65vh; + overflow: auto; + margin: 0; + padding: 1rem; + border: 1px solid #e2e8f0; + border-radius: 9px; + background: #f8fafc; + color: #243247; + font: .875rem/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.history-list { + max-height: 65vh; + overflow-y: auto; +} + +.history-entry { + display: grid; + width: 100%; + gap: .25rem; + padding: .4rem 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; +} + +.history-entry:not(:disabled):hover .history-file-name { + color: #0d6efd; +} + +.history-entry:disabled { + color: #94a3b8; + cursor: not-allowed; +} + +.history-file-name { + overflow: hidden; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.history-date { + color: #64748b; + font-size: .75rem; } .form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { @@ -28,4 +116,4 @@ body { .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; -} \ No newline at end of file +} diff --git a/src/Web/wwwroot/js/conversion.js b/src/Web/wwwroot/js/conversion.js index 0b853ff..f51bb3f 100644 --- a/src/Web/wwwroot/js/conversion.js +++ b/src/Web/wwwroot/js/conversion.js @@ -1,82 +1,157 @@ const fileInput = document.getElementById('fileInput'); const btnConvert = document.getElementById('btnConvert'); +const btnCopy = document.getElementById('btnCopy'); +const btnDownload = document.getElementById('btnDownload'); +const btnClear = document.getElementById('btnClear'); const statusText = document.getElementById('statusText'); const markdownOutput = document.getElementById('markdownOutput'); +const resultHeading = document.getElementById('resultHeading'); const historyList = document.getElementById('historyList'); +const historyEmpty = document.getElementById('historyEmpty'); const searchHistory = document.getElementById('searchHistory'); +let currentFileName = ''; + +function setMarkdown(markdown, fileName = '') { + markdownOutput.textContent = markdown || ''; + currentFileName = fileName; + resultHeading.textContent = fileName ? `${fileName} → Markdown` : 'Markdown generado'; + const hasMarkdown = markdownOutput.textContent.length > 0; + btnCopy.disabled = !hasMarkdown; + btnDownload.disabled = !hasMarkdown; +} + +async function readResponse(response) { + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body.error || 'No se pudo completar la solicitud.'); + } + return body; +} + btnConvert.addEventListener('click', async () => { const file = fileInput.files[0]; if (!file) { statusText.textContent = 'Selecciona un archivo primero.'; + fileInput.focus(); return; } const formData = new FormData(); formData.append('file', file); - statusText.textContent = 'Convirtiendo...'; btnConvert.disabled = true; try { const response = await fetch('/Conversion/Convert', { method: 'POST', body: formData }); - const result = await response.json(); - - if (result.success) { - markdownOutput.textContent = result.markdown; - statusText.textContent = `Completado en ${result.conversionTimeSeconds.toFixed(2)}s`; - loadHistory(); - } else { - statusText.textContent = result.errorMessage || 'Error al convertir.'; + const result = await readResponse(response); + if (!result.success) { + throw new Error(result.errorMessage || 'No se pudo convertir el archivo.'); } - } catch (err) { - statusText.textContent = 'Error de red.'; + + setMarkdown(result.markdown, result.fileName || file.name); + statusText.textContent = `Listo en ${Number(result.conversionTimeSeconds).toFixed(2)} s.`; + await loadHistory(searchHistory.value); + } catch (error) { + statusText.textContent = error.message || 'Error de conexión. Comprueba que la aplicación siga abierta.'; } finally { btnConvert.disabled = false; } }); -document.getElementById('btnCopy').addEventListener('click', () => { - navigator.clipboard.writeText(markdownOutput.textContent); +btnCopy.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(markdownOutput.textContent); + statusText.textContent = 'Markdown copiado al portapapeles.'; + } catch { + statusText.textContent = 'No se pudo acceder al portapapeles en este navegador.'; + } }); -document.getElementById('btnDownload').addEventListener('click', () => { - const blob = new Blob([markdownOutput.textContent], { type: 'text/markdown' }); +btnDownload.addEventListener('click', () => { + const blob = new Blob([markdownOutput.textContent], { type: 'text/markdown;charset=utf-8' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = 'resultado.md'; - a.click(); - URL.revokeObjectURL(url); + const link = document.createElement('a'); + const baseName = (currentFileName || 'documento').replace(/\.[^.]+$/, ''); + const safeName = baseName.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').trim() || 'documento'; + link.href = url; + link.download = `${safeName}.md`; + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + statusText.textContent = `Descarga iniciada: ${safeName}.md`; }); -document.getElementById('btnClear').addEventListener('click', () => { - markdownOutput.textContent = ''; +btnClear.addEventListener('click', () => { + setMarkdown(''); fileInput.value = ''; statusText.textContent = ''; + fileInput.focus(); }); async function loadHistory(search = '') { - const response = await fetch(`/Conversion/History?search=${encodeURIComponent(search)}`); - const items = await response.json(); - - historyList.innerHTML = ''; - items.forEach(item => { - const li = document.createElement('li'); - li.className = 'list-group-item list-group-item-action'; - li.style.cursor = 'pointer'; - li.textContent = item.fileName; - li.addEventListener('click', () => openHistoryItem(item.id)); - historyList.appendChild(li); - }); + try { + const response = await fetch(`/Conversion/History?search=${encodeURIComponent(search)}`); + const items = await readResponse(response); + historyList.replaceChildren(); + + items.forEach(item => { + const entry = document.createElement('li'); + entry.className = 'list-group-item px-0'; + + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'history-entry'; + button.disabled = item.status !== 'Completed'; + button.setAttribute('aria-label', `Abrir ${item.fileName}`); + + const name = document.createElement('span'); + name.className = 'history-file-name'; + name.textContent = item.fileName; + + const detail = document.createElement('span'); + detail.className = 'history-date'; + detail.textContent = item.status === 'Completed' + ? new Date(item.createdAt).toLocaleString() + : 'No se pudo convertir'; + + button.append(name, detail); + button.addEventListener('click', () => openHistoryItem(item.id)); + entry.appendChild(button); + historyList.appendChild(entry); + }); + + historyEmpty.hidden = items.length > 0; + historyEmpty.textContent = search ? 'No hay resultados para esa búsqueda.' : 'Aún no hay conversiones guardadas.'; + } catch { + historyEmpty.hidden = false; + historyEmpty.textContent = 'No se pudo cargar el historial.'; + } } async function openHistoryItem(id) { - const response = await fetch(`/Conversion/Item?id=${id}`); - const result = await response.json(); - markdownOutput.textContent = result.markdown; + statusText.textContent = 'Abriendo conversión...'; + try { + const response = await fetch(`/Conversion/Item?id=${encodeURIComponent(id)}`); + const result = await readResponse(response); + setMarkdown(result.markdown, result.fileName); + statusText.textContent = result.markdown ? 'Conversión cargada desde el historial.' : 'Esta conversión no tiene Markdown disponible.'; + } catch (error) { + statusText.textContent = error.message; + } } -searchHistory.addEventListener('input', (e) => loadHistory(e.target.value)); +let historySearchTimer; +searchHistory.addEventListener('input', event => { + clearTimeout(historySearchTimer); + historySearchTimer = setTimeout(() => loadHistory(event.target.value), 200); +}); + +fileInput.addEventListener('change', () => { + if (fileInput.files[0]) { + statusText.textContent = `${fileInput.files[0].name} seleccionado.`; + } +}); loadHistory(); diff --git a/tests/MarkItDownWeb.Tests/FileSignatureValidatorTests.cs b/tests/MarkItDownWeb.Tests/FileSignatureValidatorTests.cs new file mode 100644 index 0000000..7bd68e2 --- /dev/null +++ b/tests/MarkItDownWeb.Tests/FileSignatureValidatorTests.cs @@ -0,0 +1,68 @@ +using MarkItDownWeb.Infrastructure.Security; +using Xunit; + +namespace MarkItDownWeb.Tests; + +public class FileSignatureValidatorTests +{ + [Fact] + public void AcceptsPdfWithPdfHeader() + => AssertValid(".pdf", [0x25, 0x50, 0x44, 0x46, 0x2D]); + + [Theory] + [InlineData(".docx")] + [InlineData(".pptx")] + [InlineData(".xlsx")] + public void AcceptsOfficeFilesWithZipHeader(string extension) + => AssertValid(extension, [0x50, 0x4B, 0x03, 0x04, 0x14, 0x00]); + + [Theory] + [InlineData(".pdf")] + [InlineData(".docx")] + [InlineData(".pptx")] + [InlineData(".xlsx")] + public void RejectsContentThatDoesNotMatchExtension(string extension) + => AssertInvalid(extension, [0x00, 0x01, 0x02, 0x03]); + + [Fact] + public void RejectsFileShorterThanSignature() + => AssertInvalid(".pdf", [0x25, 0x50]); + + [Theory] + [InlineData(".html")] + [InlineData(".csv")] + public void TextFormatsDoNotRequireBinarySignature(string extension) + => AssertValid(extension, [0x00]); + + private static void AssertValid(string extension, byte[] contents) + { + using var file = TemporaryFile.Create(extension, contents); + Assert.True(FileSignatureValidator.IsValid(file.Path, extension)); + } + + private static void AssertInvalid(string extension, byte[] contents) + { + using var file = TemporaryFile.Create(extension, contents); + Assert.False(FileSignatureValidator.IsValid(file.Path, extension)); + } + + private sealed class TemporaryFile : IDisposable + { + private TemporaryFile(string path) => Path = path; + + public string Path { get; } + + public static TemporaryFile Create(string extension, byte[] contents) + { + var path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"{Guid.NewGuid()}{extension}"); + File.WriteAllBytes(path, contents); + return new TemporaryFile(path); + } + + public void Dispose() + { + if (File.Exists(Path)) + File.Delete(Path); + } + } +} diff --git a/tests/MarkItDownWeb.Tests/MarkItDownWeb.Tests.csproj b/tests/MarkItDownWeb.Tests/MarkItDownWeb.Tests.csproj new file mode 100644 index 0000000..c2ccc91 --- /dev/null +++ b/tests/MarkItDownWeb.Tests/MarkItDownWeb.Tests.csproj @@ -0,0 +1,22 @@ + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + net10.0 + enable + enable + + +