Files
tiennm99 933adf70c9 refactor: comments state current behavior, not project history
Comments across the tree justified the code by pointing at a Rust
implementation that is no longer in the repository, citing files and line
numbers (config.rs:132, schema.rs:26-54, reader.rs:42) that cannot be
opened, plus crates and datasets that are equally gone. A reader could not
check any of it.

Every invariant those comments carried is kept and restated so it stands on
its own: the bytewise sort that decides which row survives a duplicate exam
number, the literal U+0300..U+036F range that must match the site's toAscii,
the trailing space in "SINH ", the BIFF and shared-string corrections, the
VACUUM-after-COMMIT rule, the deploy-from-main guard.

The reader's contract is now anchored to the frozen oracle in
parser/testdata, which still exists and is still checked, rather than to the
tool that originally produced it.

TestDDLMatchesRust becomes TestDDLIsFrozen: it compares against a copy of
the DDL inside the test and never read schema.rs, so both the name and the
failure message were misleading.

ToAscii no longer claims the d-replacement must precede lowercasing. Both
cases map to 'd' and ToLower runs last, so the order has no effect.
2026-08-14 09:40:41 +07:00

157 lines
4.2 KiB
Go

package reader
import (
"archive/zip"
"bytes"
"encoding/xml"
"io"
"strconv"
"github.com/xuri/excelize/v2"
)
// normalizeNumeric reproduces the reference rendering of a numeric cell.
//
// The reference parses a numeric cell to a float and prints its shortest
// round-tripping form, so the stored literal "6.0" becomes "6". RawCellValue
// hands back the literal instead.
//
// The cell type must be consulted, not guessed: "01063476", "6.00" and "NAN" are
// all shared strings that survive ParseFloat, and renumbering them would drop a
// leading zero, drop a trailing zero, or recase NaN. GetCellType is only called
// when re-rendering would actually change the text, which keeps it off the hot
// path for the ~99% of cells that are already canonical or plainly non-numeric.
func normalizeNumeric(f *excelize.File, sheet string, col, row int, v string) string {
if v == "" {
return v
}
fv, err := strconv.ParseFloat(v, 64)
if err != nil {
return v
}
out := strconv.FormatFloat(fv, 'f', -1, 64)
if out == v {
return v
}
axis, err := excelize.CoordinatesToCellName(col+1, row+1)
if err != nil {
return v
}
// OOXML omits the t attribute on numeric cells, and excelize has no map
// entry for an empty t, so a plain number reports CellTypeUnset rather than
// CellTypeNumber. Unset is only reachable here for a cell that exists and
// parsed as a float — an absent cell is "" and returned above — so both
// values mean "numeric". Shared strings report CellTypeSharedString and are
// left alone, which is what protects "01063476", "6.00" and "NAN".
if t, err := f.GetCellType(sheet, axis); err == nil &&
(t == excelize.CellTypeNumber || t == excelize.CellTypeUnset) {
return out
}
return v
}
// buildCRFixups maps a shared string's line-ending-normalised form back to its
// raw form, for the strings that contain a carriage return.
//
// Go's encoding/xml performs the line-ending normalisation the XML 1.0 spec
// mandates (CRLF and lone CR both become LF), so excelize returns "a\nb" where
// the reference — which reads the raw bytes — returns "a\r\nb". That difference
// reaches the database: in one 2016 file it affects 2,233 TEN_CUMTHI values,
// which populate the ten_cum_thi column.
//
// The trick is that character references are exempt from that normalisation, so
// rewriting literal CR bytes to 
 before decoding round-trips them intact.
//
// Returns nil when the file has no CR at all, which is the common case.
func buildCRFixups(path string) map[string]string {
zr, err := zip.OpenReader(path)
if err != nil {
return nil
}
defer zr.Close()
var entry *zip.File
for _, zf := range zr.File {
if zf.Name == "xl/sharedStrings.xml" {
entry = zf
break
}
}
if entry == nil {
return nil
}
rc, err := entry.Open()
if err != nil {
return nil
}
defer rc.Close()
data, err := io.ReadAll(rc)
if err != nil || !bytes.ContainsRune(data, '\r') {
return nil
}
data = bytes.ReplaceAll(data, []byte{'\r'}, []byte("
"))
fixups := make(map[string]string)
dec := xml.NewDecoder(bytes.NewReader(data))
var cur bytes.Buffer
inSI, inT := false, false
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
switch t.Name.Local {
case "si":
inSI, cur = true, bytes.Buffer{}
case "t":
inT = true
}
case xml.CharData:
if inSI && inT {
cur.Write(t)
}
case xml.EndElement:
switch t.Name.Local {
case "t":
inT = false
case "si":
if inSI {
raw := cur.String()
if norm := normalizeEOL(raw); norm != raw {
fixups[norm] = raw
}
}
inSI = false
}
}
}
if len(fixups) == 0 {
return nil
}
return fixups
}
// normalizeEOL applies XML 1.0 line-ending normalisation: CRLF and lone CR
// both collapse to LF. This is what encoding/xml does to the raw bytes, so it
// reproduces the text excelize hands back.
func normalizeEOL(s string) string {
if !bytes.ContainsRune([]byte(s), '\r') {
return s
}
var b bytes.Buffer
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\r' {
if i+1 < len(s) && s[i+1] == '\n' {
continue // CRLF: the LF is emitted on the next pass
}
b.WriteByte('\n') // lone CR
continue
}
b.WriteByte(s[i])
}
return b.String()
}