엑셀 일괄등록: 일부 엑셀 파일 읽기 실패 버그 수정

"custom numFmtId starts at 164 but found a value of 0" 에러 -
구글시트/WPS 등에서 내보낸 일부 xlsx 파일은 styles.xml에 커스텀
숫자서식 id를 164 미만으로 적어두는데, excel 패키지가 이를 엄격하게
검증하며 예외를 던져 파일 전체를 못 읽었다. 파싱 전에 zip 안의
styles.xml만 열어 그 규격 위반 서식 선언을 제거하고 다시 압축해서
넘기도록 전처리 단계를 추가. 합성 xlsx로 버그 재현 + 수정 후 정상
파싱까지 직접 검증함.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:06:25 +09:00
co-authored by Claude Sonnet 5
parent 80667d20f1
commit b6e77ad1fa
3 changed files with 68 additions and 2 deletions
@@ -1,5 +1,6 @@
// 👥 교사용 학생 계정 관리 화면의 기능(서버 통신/상태/엑셀 일괄등록) 담당 컨트롤러.
import 'dart:convert';
import 'package:archive/archive.dart';
import 'package:excel/excel.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
@@ -91,7 +92,7 @@ class TeacherStudentManagementController extends ChangeNotifier {
/// A열=학번, B열=이름, C열=학년(선택, 숫자) 순서를 기대한다.
/// 학번이나 이름이 비어있는 줄은 조용히 무시한다.
List<BulkStudentRow> parseExcelBytes(Uint8List bytes) {
final excel = Excel.decodeBytes(bytes);
final excel = Excel.decodeBytes(_sanitizeXlsxBytes(bytes));
if (excel.tables.isEmpty) return [];
final sheet = excel.tables.values.first;
@@ -108,6 +109,70 @@ class TeacherStudentManagementController extends ChangeNotifier {
return rows;
}
/// 🩹 [엑셀 파싱 버그 우회] 일부 엑셀 파일(구글시트/WPS 등에서 내보낸 파일 포함)은
/// styles.xml에 커스텀 숫자서식 id를 164 미만(예: 0)으로 잘못 적어두는데, excel
/// 패키지가 이걸 엄격하게 걸러내며 예외를 던져서 파일 전체를 못 읽게 만든다.
/// 우리는 셀 값(학번/이름/학년)만 필요하고 서식은 필요 없으니, zip 안의 styles.xml만
/// 열어서 그 잘못된 커스텀 서식 선언을 지우고 다시 압축해서 넘긴다.
Uint8List _sanitizeXlsxBytes(Uint8List bytes) {
try {
final zip = ZipDecoder().decodeBytes(bytes);
final stylesFile = zip.files.cast<ArchiveFile?>().firstWhere(
(f) => f?.name == 'xl/styles.xml',
orElse: () => null,
);
if (stylesFile == null) return bytes;
final xml = utf8.decode(stylesFile.content as List<int>);
final fixedXml = _stripInvalidCustomNumFmts(xml);
if (fixedXml == xml) return bytes; // 고칠 게 없었으면 원본 그대로.
final newZip = Archive();
for (final file in zip.files) {
if (!file.isFile) continue;
if (file.name == 'xl/styles.xml') {
final content = utf8.encode(fixedXml);
newZip.addFile(ArchiveFile('xl/styles.xml', content.length, content));
} else {
newZip.addFile(ArchiveFile(file.name, file.size, file.content));
}
}
final rezipped = ZipEncoder().encode(newZip);
return rezipped != null ? Uint8List.fromList(rezipped) : bytes;
} catch (_) {
return bytes; // 손대다 실패하면 원본 그대로 시도 (기존 동작 유지).
}
}
/// styles.xml 문자열에서 `<numFmt numFmtId="N" .../>` 태그 중 N이 164 미만인
/// (규격 위반인) 것만 통째로 제거한다. 그 서식을 참조하던 셀은 기본 서식으로 처리될
/// 뿐이라 값 읽기에는 영향이 없다.
String _stripInvalidCustomNumFmts(String xml) {
final buffer = StringBuffer();
int i = 0;
while (true) {
final tagStart = xml.indexOf('<numFmt ', i);
if (tagStart == -1) {
buffer.write(xml.substring(i));
break;
}
final tagEnd = xml.indexOf('/>', tagStart);
if (tagEnd == -1) {
buffer.write(xml.substring(i));
break;
}
buffer.write(xml.substring(i, tagStart));
final tag = xml.substring(tagStart, tagEnd + 2);
final idMatch = RegExp(r'numFmtId="(\d+)"').firstMatch(tag);
final id = idMatch != null ? int.tryParse(idMatch.group(1)!) : null;
if (id == null || id >= 164) {
buffer.write(tag); // 정상 범위의 커스텀 서식은 그대로 둔다.
}
i = tagEnd + 2;
}
return buffer.toString();
}
String? _cellText(CellValue? value) {
if (value == null) return null;
String text;
+1 -1
View File
@@ -18,7 +18,7 @@ packages:
source: hosted
version: "1.0.3"
archive:
dependency: transitive
dependency: "direct main"
description:
name: archive
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
+1
View File
@@ -54,6 +54,7 @@ dependencies:
# 📳 무단 반출 감지 시 진동 (백그라운드 서비스에서도 동작하도록 HapticFeedback 대신 사용)
vibration: ^3.2.0
excel: ^4.0.6
archive: ^3.6.1
file_picker: ^12.1.2
desktop_drop: ^0.8.3
web: ^1.1.1