# Escribe el cuaderno de práctica en Excel y lo deja limpio para publicarlo. Lo llama cuaderno.mjs. # # Uso: pwsh -NoProfile -File cuaderno.ps1 -Trabajo cuaderno.json -Salida cuaderno-de-practica.xlsx # # cuaderno.json: { "hojas": [ { "nombre": "Ventas", "filas": [[...]], "negrita": [1], "fechas": ["A"], # "formatos": [ { "rango": "E2:E21", "formato": "entero" } ] } ], "titulo": "…" } # Formatos: entero, decimal, porcentaje, fecha o texto. # Los números de fecha llegan como número de serie de Excel. Un texto que empieza por «'» queda como texto (así una # fórmula se ve y no se calcula). # # Después de guardar, abre el .xlsx (un .zip) y quita lo que diría de qué equipo salió: el autor y la última persona # que lo guardó pasan a «Shinacer», la empresa se borra y la ruta absoluta del libro (x15ac:absPath) desaparece. # Excel se abre en una instancia propia y oculta, sin tocar las opciones guardadas del usuario, y se cierra al acabar. param( [Parameter(Mandatory)] [string] $Trabajo, [Parameter(Mandatory)] [string] $Salida ) $ErrorActionPreference = 'Stop' # La salida, en UTF-8: los nombres de las hojas llevan tildes [Console]::OutputEncoding = [Text.UTF8Encoding]::new($false) Add-Type -AssemblyName System.IO.Compression, System.IO.Compression.FileSystem Add-Type -Namespace Shinacer -Name Ventana -MemberDefinition @' [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); '@ $encargo = Get-Content -Raw -Encoding utf8 $Trabajo | ConvertFrom-Json -AsHashtable $Salida = [IO.Path]::GetFullPath($Salida) function ConvertirA-Matriz($filas) { $columnas = ($filas | ForEach-Object { $_.Count } | Measure-Object -Maximum).Maximum $m = New-Object 'object[,]' $filas.Count, $columnas for ($i = 0; $i -lt $filas.Count; $i++) { for ($j = 0; $j -lt $filas[$i].Count; $j++) { $v = $filas[$i][$j] if ($null -eq $v) { continue } $m[$i, $j] = if ($v -is [string]) { $v } else { [double] $v } } } return , $m } # Los formatos llegan por nombre y se escriben con NumberFormatLocal, con los separadores y las letras de fecha que da # el propio Excel. Por COM, en este Excel (interfaz en español, región de Colombia), NumberFormat también se lee en la # forma local: «#,##0» salía «954650,0», y «dd/mm/yyyy», «25/08/yyyy» function Formato-Local($excel, [string] $nombre) { $miles = $excel.International(4) # xlThousandsSeparator $decimal = $excel.International(3) # xlDecimalSeparator $dia = $excel.International(21) # xlDayCode $mes = $excel.International(20) # xlMonthCode $anio = $excel.International(19) # xlYearCode switch ($nombre) { 'entero' { "#$($miles)##0" } 'decimal' { "#$($miles)##0$($decimal)00" } 'porcentaje' { "0$($decimal)0%" } 'fecha' { "$dia$dia/$mes$mes/$anio$anio$anio$anio" } 'texto' { '@' } default { throw "Formato desconocido: $nombre" } } } $excel = $null $proceso = 0 try { $excel = New-Object -ComObject Excel.Application [uint32] $pid32 = 0 [void][Shinacer.Ventana]::GetWindowThreadProcessId([IntPtr] $excel.Hwnd, [ref] $pid32) $proceso = [int] $pid32 $excel.Visible = $false $excel.DisplayAlerts = $false $excel.ScreenUpdating = $false $excel.EnableEvents = $false $libro = $excel.Workbooks.Add(-4167) try { $primera = $true foreach ($h in $encargo.hojas) { if ($primera) { $hoja = $libro.Worksheets.Item(1); $primera = $false } else { $hoja = $libro.Worksheets.Add([Type]::Missing, $libro.Worksheets.Item($libro.Worksheets.Count)) } $hoja.Name = $h.nombre $matriz = ConvertirA-Matriz $h.filas $hoja.Range('A1').Resize($matriz.GetLength(0), $matriz.GetLength(1)).Value2 = $matriz foreach ($c in $h.fechas) { $hoja.Columns.Item($c).NumberFormatLocal = Formato-Local $excel 'fecha' } foreach ($f in $h.formatos) { $hoja.Range($f.rango).NumberFormatLocal = Formato-Local $excel $f.formato } foreach ($fila in $h.negrita) { $hoja.Rows.Item($fila).Font.Bold = $true } if ($h.ajustar) { foreach ($c in $h.ajustar) { $hoja.Columns.Item($c).WrapText = $true; $hoja.Columns.Item($c).ColumnWidth = 90 } } [void]$hoja.UsedRange.Columns.AutoFit() if ($h.ajustar) { foreach ($c in $h.ajustar) { $hoja.Columns.Item($c).ColumnWidth = 90 } } [void]$hoja.UsedRange.Rows.AutoFit() } $libro.Worksheets.Item(1).Activate() if (Test-Path $Salida) { Remove-Item $Salida } $libro.SaveAs($Salida, 51) # xlOpenXMLWorkbook } finally { $libro.Close($false) [void][Runtime.InteropServices.Marshal]::ReleaseComObject($libro) } } finally { if ($excel) { try { $excel.Quit() } catch {} [void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel) } [GC]::Collect() [GC]::WaitForPendingFinalizers() if ($proceso) { Start-Sleep -Milliseconds 500 $p = Get-Process -Id $proceso -ErrorAction SilentlyContinue if ($p -and $p.ProcessName -eq 'EXCEL') { Stop-Process -Id $proceso -Force } } } # ─── Limpieza del paquete ───────────────────────────────────────────────────────────────────────────────────────── $fecha = '2026-10-09T00:00:00Z' $titulo = [Security.SecurityElement]::Escape($encargo.titulo) $propiedades = '' + "`r`n" + '' + "$tituloShinacerShinacer" + "$fecha$fecha" + '' function Reescribir($zip, [string] $nombre, [scriptblock] $cambio) { $entrada = $zip.GetEntry($nombre) if (-not $entrada) { return } $lector = New-Object IO.StreamReader($entrada.Open(), [Text.Encoding]::UTF8) $texto = $lector.ReadToEnd() $lector.Dispose() $nuevo = & $cambio $texto $entrada.Delete() $escritor = New-Object IO.StreamWriter($zip.CreateEntry($nombre).Open(), (New-Object Text.UTF8Encoding($false))) $escritor.Write($nuevo) $escritor.Dispose() } $zip = [IO.Compression.ZipFile]::Open($Salida, 'Update') try { Reescribir $zip 'docProps/core.xml' { param($t) $propiedades } Reescribir $zip 'docProps/app.xml' { param($t) ($t -replace '[^<]*', '') -replace '[^<]*', '' } Reescribir $zip 'xl/workbook.xml' { param($t) $t -replace ']*>\s*]*>\s*]*/>\s*\s*', '' } } finally { $zip.Dispose() } # ─── Revisión: lo que no puede quedar dentro, y que Excel lo abra ───────────────────────────────────────────────── $prohibidos = @($env:USERNAME, $env:COMPUTERNAME, 'Users\', 'Users/', 'C:\', 'absPath', 'OneDrive') | Where-Object { $_ } # Y cualquier correo que no sea de example.com (los de la hoja de contactos, inventados, lo son) $correo = '[A-Za-z0-9._%+-]+@(?!example\.com\b)[A-Za-z0-9-]+\.[A-Za-z]{2,}' $hallazgos = @() $zip = [IO.Compression.ZipFile]::OpenRead($Salida) try { $entradas = @($zip.Entries | ForEach-Object { $_.FullName }) foreach ($entrada in $zip.Entries) { $lector = New-Object IO.StreamReader($entrada.Open(), [Text.Encoding]::UTF8) $texto = $lector.ReadToEnd() $lector.Dispose() foreach ($p in $prohibidos) { if ($texto.IndexOf($p, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $hallazgos += "$($entrada.FullName): $p" } } if ($texto -match $correo) { $hallazgos += "$($entrada.FullName): un correo ($($Matches[0]))" } } $propiedadesLeidas = (New-Object IO.StreamReader($zip.GetEntry('docProps/core.xml').Open())).ReadToEnd() } finally { $zip.Dispose() } $excel = $null $proceso = 0 $abre = $null try { $excel = New-Object -ComObject Excel.Application [uint32] $pid32 = 0 [void][Shinacer.Ventana]::GetWindowThreadProcessId([IntPtr] $excel.Hwnd, [ref] $pid32) $proceso = [int] $pid32 $excel.Visible = $false $excel.DisplayAlerts = $false $libro = $excel.Workbooks.Open($Salida, 0, $true) # sin actualizar vínculos, solo lectura $abre = [ordered]@{ hojas = @($libro.Worksheets | ForEach-Object { $_.Name }) muestra = [string] $libro.Worksheets.Item(2).Range('E2').Text } $libro.Close($false) [void][Runtime.InteropServices.Marshal]::ReleaseComObject($libro) } finally { if ($excel) { try { $excel.Quit() } catch {} [void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel) } [GC]::Collect() [GC]::WaitForPendingFinalizers() if ($proceso) { Start-Sleep -Milliseconds 500 $p = Get-Process -Id $proceso -ErrorAction SilentlyContinue if ($p -and $p.ProcessName -eq 'EXCEL') { Stop-Process -Id $proceso -Force } } } [ordered]@{ entradas = $entradas; hallazgos = $hallazgos; propiedades = $propiedadesLeidas; abre = $abre } | ConvertTo-Json -Depth 4 -Compress