# 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