내보낸 PDF는 각 열 경계를 Excel이 그리는 위치에서 반 자 왼쪽에 놓고, 이제 모든 줄바꿈 셀이 다른 곳에서 끊깁니다. Excel 열 너비는 문자나 포인트로 측정되지 않습니다. 통합 문서 Normal 폰트의 Max Digit Width(MDW) 단위로 측정되며, HotXLS는 페이지 매김 빌드 전마다 그 폰트를 GDI로 측정합니다. 실패 양상은 조용합니다. 아무것도 던져지지 않고, 저장된 너비는 바이트 단위로 라운드트립되며, 기하는 누적된 드리프트가 한 페이지 표를 두 페이지로 밀어 낼 때까지 열마다 몇 퍼센트씩 어긋난 채로 있습니다
Excel 열 너비는 무슨 단위인가
워크시트의 열 너비는 절대 측정이 아니라 통합 문서 Normal 폰트의 숫자 문자 개수입니다. ECMA-376 §18.3.1.13은 <col>의 width 속성을 96 dpi에서 그 폰트의 Maximum Digit Width로 정의하고, 저장된 너비에서 픽셀로의 변환을 MDW 위의 버림 표현식으로 줍니다. Excel이 Normal 스타일로 실어 나가는 Calibri 11에서 MDW는 7픽셀로 측정됩니다. 기본 너비 8.43 단위를 MDW 7로 명세 공식에 넣으면 정확히 64픽셀이 나오고, 96 dpi에서 48포인트입니다. 그것들은 Excel 자신이 보고하는 숫자들이므로 유용한 검사가 됩니다. 변환이 8.43 단위를 64픽셀로 재현한다면 산술은 맞고 여전히 틀릴 수 있는 것은 MDW 입력뿐입니다
const
// 96 dpi에서 기본 본문 폰트의 최대 자릿수 너비(MDW), 픽셀 단위.
// Calibri 11은 7px로 측정되며, Excel이 저장하는 정확한 픽셀 너비를
// 재현한다 (8.43 단위 -> 64 px -> 48 pt).
DefaultMDW = 7;
MinimumColumnWidth = 24.0;
function ColumnWidthToPointsMdW(Value: Double; MdW: Integer): Double;
var
Pixels: Integer;
begin
if Value <= 0 then
Value := 8.43;
if MdW <= 0 then
MdW := DefaultMDW;
Pixels := Trunc(((256 * Value + Trunc(128 / MdW)) / 256) * MdW) + 5;
Result := Pixels * 0.75; // 96 dpi 픽셀 -> 포인트
if Result < MinimumColumnWidth then
Result := MinimumColumnWidth;
end;
HotXLS는 그 산술을 lxPagination 유닛의 정확히 하나의 함수에 둡니다. 그래서 자가 틀릴 수 있는 곳이 단 하나입니다. + 5는 Excel이 격자선과 셀 여백을 위해 더하는 패딩이고, * 0.75는 96 dpi 픽셀을 PostScript 포인트로 바꾸며, MinimumColumnWidth에서의 바닥은 병적으로 좁은 열도 렌더러가 테두리를 그릴 수 있는 띠를 남기기 위해 존재합니다. 공개 진입점 ColumnWidthToPoints는 낡은 단일 인수 시그니처를 유지하며 측정된 MDW를 이 함수로 전달합니다. 그래서 행동 변화가 호출 사이트 하나 건드리지 않고 착지할 수 있었습니다
Calibri가 아닌 Normal 폰트가 모든 경계를 움직이는 이유
드리프트는 곱셈적입니다. 그래서 단위 버그가 아니라 렌더링 버그로 읽힙니다. MDW는 오프셋이 아니라 너비에 대한 인자입니다. MDW를 7에서 8로 밀면 기본 8.43 단위 열은 64픽셀에서 72로 갑니다. 한 열에서 8픽셀, 6포인트 점프입니다. 그것이 열 열이면 표의 오른쪽 끝이 거의 1인치 움직입니다. 이것을 걸리게 하는 통합 문서는 전혀 특별하지 않습니다. Arial이나 Segoe UI를 Normal 스타일에 찍는 보고 도구가 만든 것, ERP 내보내기 템플릿에서 저장된 것, 고객이 한 번 스타일을 바꾸고 잊어버린 것 전부입니다
두 관련 레이아웃 시스템은 그 오류를 일으키는 게 아니라 상속합니다. 병합 영역은 멤버 열의 포인트 너비를 합산하므로 Excel에서 한 페이지에 맞던 병합이 MDW 드리프트 뒤에 넘칠 수 있습니다. 병합 셀 보고서 템플릿을 만들 때 기억할 가치가 있습니다. 맞춤 축소는 측정된 텍스트 너비를 같은 열 너비와 비교하므로, 틀린 MDW는 어떤 셀이 줄어드는지, 얼마나 줄어드는지도 바꿉니다. 같은 부류의 단위 혼동이 그리기 앵커에도 나타납니다. 이미지 기하와 EMU 스케일링에는 틀리기만을 기다리는 자기 변환 체인이 있습니다
HotXLS가 런타임에 MDW를 측정하는 방법
HotXLS는 상수를 가정하는 대신 통합 문서 자신에서 MDW를 해석하고, 두 프로시저가 그 일을 합니다. PaginationApplyNormalFont는 통합 문서에서 Normal 스타일 폰트를 읽고 어떤 열 기하도 계산되기 전에 페이지 매김 빌드 상단에서 돕니다. 먼저 Calibri 11으로 리셋하므로 폰트 테이블이 없는 통합 문서가 이전 빌드의 낡은 상태를 상속할 수 없습니다. Normal 스타일 폰트는 styles.xml의 fonts[0]이고, 컴포넌트는 그것을 Workbook.Fonts[0]으로 노출합니다
// 워크시트 통합 문서에서 fonts[0](Normal 스타일 폰트)을 읽는다.
// 폰트 테이블이 없는 클래식 워크시트는 Calibri 11 기본값을 유지한다.
procedure PaginationApplyNormalFont(Worksheet: TObject);
var
Sh: TXLSXWorksheet;
Fnt: TXLSXFont;
begin
PaginationNormalFontName := 'Calibri';
PaginationNormalFontSize := 11;
if not (Worksheet is TXLSXWorksheet) then
Exit;
Sh := TXLSXWorksheet(Worksheet);
if (Sh.Workbook = nil) or (Sh.Workbook.Fonts.Count < 1) then
Exit;
Fnt := Sh.Workbook.Fonts[0];
if Fnt.Name <> '' then
PaginationNormalFontName := Fnt.Name;
if Fnt.Size > 0 then
PaginationNormalFontSize := Fnt.Size;
end;
둘째 프로시저 PaginationMeasureMdW는 공유 오프스크린 비트맵 캔버스에서 GetTextExtentPoint32W로 단일 문자 '0'의 범위를 GDI에 묻고, 범위 호출이 실패하면 GetTextMetricsW의 tmAveCharWidth로 폴백하고, 둘 다 없으면 DefaultMDW로 폴백합니다. 그 캐시는 (name, size)로 키 잡힌 단일 슬롯인데, 접근 패턴을 보기 전에는 조잡하게 들립니다. 페이지 매김 빌드는 모든 페이지의 모든 열에서 같은 Normal 폰트를 묻으므로, 하나의 슬롯이 거의 완벽한 히트율을 가지고 호출당 비교 셋의 비용이 듭니다
폰트 테이블이 없거나 GUI가 없거나 폰트가 없으면 무슨 일이 벌어지는가
HotXLS는 진짜 Normal 폰트를 결정할 수 없는 모든 경우에 Calibri 11 상수로 저하하고, 설계상 조용히 그렇게 합니다. 클래식 BIFF 워크시트가 흔한 경우입니다. 레거시 포맷은 fonts[0]이 참조할 XLSX 폰트 풀을 실지 않으므로, 타입 가드가 일찍 빠져나가고 기본 MDW 7이 섭니다. 그것은 고침이 아니라 예전 행동을 일부러 보존한 것입니다. 그래야 측정을 XLSX 경로에 더하는 것이 클래식 포맷 출력을 회귀시킬 수 없었습니다
GDI 의존이 정직한 단서입니다. 측정은 윈도우 장치 컨텍스트에 대해 돌므로, 경로는 폰트가 설치된 윈도우 호스트를 가정합니다. 서비스나 헤드리스 빌드 에이전트에서 GDI 텍스트 메트릭은 대체로 여전히 해석되지만, 그 기계에 설치되지 않은 폰트는 폰트 매퍼가 대체하고 여러분은 대체품을 측정합니다. 요란히 실패하지 않습니다. 잘못된 서체에 그럴듯한 숫자를 돌려줍니다. 서버 측 내보내기가 데스크톱 참조와 일치해야 한다면 템플릿이 이름 짓는 폰트를 내보내기 호스트에 설치하거나, 워크시트 PDF 내보내기 경로를 부르기 전에 Normal 폰트를 고정하십시오
var
Book: TXLSXWorkbook;
Exporter: TXLSPDFExport;
begin
Book := TXLSXWorkbook.Create;
Exporter := TXLSPDFExport.Create;
try
Book.Open('quarterly-report.xlsx');
// Normal 폰트를 고정해서 이 호스트에서 측정된 MDW가
// 레이아웃이 설계된 대상이 되도록 한다. 폰트 매퍼 대체품이 아니라.
if Book.Fonts.Count > 0 then
begin
Book.Fonts[0].Name := 'Calibri';
Book.Fonts[0].Size := 11;
end;
Exporter.UseWorksheetPageSetup := True;
Exporter.SaveAsPDF(Book, 'quarterly-report.pdf');
finally
Exporter.Free;
Book.Free;
end;
end;
측정 캐시, 그리고 Win64에서 터진 하나
텍스트 측정이 곱셈이 아니라 GDI 왕복이 되는 순간 캐시해야 하고, 렌더 패스 안에서의 캐싱이 이 작업이 피를 본 곳입니다. 맞춤 축소 루프는 폰트 크기를 0.5pt 단위로 낮추며 매 단계 뒤 재측정하므로, 한 셀이 같은 문자열로 PaginationMeasureTextWidth를 한두 번 이상 부를 수 있고, 단어 줄바꿈은 후보 줄마다 다시 부릅니다. 폰트 이름, 크기, 텍스트로 키 잡힌 메모는 그것을 서로 다른 문자열마다 한 번의 GDI 호출로 접어 TStringList에 이름/값 쌍으로 저장합니다
그와 함께 더해진 다른 캐시는 그리 단정하지 않았습니다. 렌더 패스 5는 셀마다 FontIndex로 폰트 풀을 해석하고, 그 메모는 손으로 관리하는 FontMemoCount와 평행 동적 배열을 썼습니다. 첫 버전은 각 페이지 시작에서 ResetFontMemo를 부르는 것을 잊었고, 배열은 그렇지 않은 동안 카운트는 페이지를 넘어 계속 올라갔고, 코드는 그 모두의 끝을 지나 썼습니다. Win32에서는 조용히 옆 힙에 낙서하고 끝났습니다. Win64에서는 즉시 0x538에 쓰기로 접근 위반이 일었습니다. 일반화할 수 있는 교훈. 유닛 레벨 변수에 들어 있는 배열 기반 캐시는 그것을 쓰는 모든 패스의 진입에서 리셋되어야 합니다. 문자열 목록이나 사전은 빠진 리셋을 자라는 것으로 용서하지만 평행 배열은 용서하지 않기 때문입니다
자기 변환 검사하기
이 모든 것을 검증하는 데 컴포넌트는 필요 없습니다. Normal 폰트가 Calibri 11이 아닌 통합 문서를 골라 <col width="..."/>에서 너비를 읽고, 명세 공식에 두 번 돌리십시오. MDW 7로 한 번, 여러분 렌더러가 그 폰트에 대해 실제로 측정하는 MDW로 한 번. 답이 어긋나고 출력이 첫 번째와 맞는다면 드리프트를 찾은 것입니다. 열 기하는 스프레드시트 엔진의 보이지 않거나 유일하게 눈에 띄는 부분 중 하나이고, 그것을 바르게 한다는 것은 Normal 폰트를 스타일 세부가 아니라 레이아웃의 입력으로 다룬다는 뜻입니다. Office 설치 없이 Excel 통합 문서를 읽고, 쓰고, 렌더하고, 인쇄하는 델파이나 C++Builder 애플리케이션을 만든다면, HotXLS Delphi Excel component가 MDW 측정, 페이지 매김 모델, 하나의 VCL 클래스 집합 뒤의 PDF 파이프라인을 처리합니다