기술 문서

Delphi의 HotXLS 이미지 기하학: EMU, cm, 그리고 Scaling

생성한 송장 헤더에 600×400 pixel 로고를 하나 넣었다고 하자. 96-DPI 개발용 모니터에서는 딱 맞아 보였는데, 일주일 뒤 고해상도 노트북을 쓰는 고객이 그것이 우표만 한 크기로 출력된다고 알려 온다. 픽셀 수는 변하지 않았다. 달라진 것은 픽셀 개수가 물리적 크기를 뜻한다는 가정이며, OOXML에서는 그런 가정이 성립하지 않는다. 스프레드시트 이미지는 크기를 EMU로 저장하고, EMU 혹은 그것과 깨끗하게 대응하는 현실 단위로 생각하기 전까지는 당신의 레이아웃이 렌더링 머신이 임의로 가정한 DPI에 휘둘리게 된다

HotXLS는 Excel이나 COM 의존성 없이 XLS와 XLSX를 읽고 쓰는 Delphi와 C++Builder용 네이티브 VCL spreadsheet 컴포넌트다. v2.91.0부터는 XLSX image object가 더 이상 단위 계산을 손으로 시키지 않는다. raw EMU 외에 centimetre, inch, point 기준의 너비와 높이를 제공하고, 선택적으로 가로세로 비율을 고정한 채 퍼센트로 크기를 바꾸는 Scale 메서드도 노출한다. 이 글은 EMU가 실제로 무엇인지, DrawingML이 왜 그런 단위를 선택했는지, 그리고 픽셀 수가 아니라 물리적 크기를 기준으로 그림을 배치하려면 이 새 geometry surface를 어떻게 써야 하는지를 다룬다

EMU가 무엇이고 DrawingML이 왜 쓰는가

EMU는 English Metric Unit의 약자이며, Office Open XML 전체가 공유하는 DrawingML 그리기 계층(ECMA-376 Part 1 §20)의 기본 길이 단위다. 1 EMU는 1 inch당 정확히 914400 EMU, 그리고 1 centimetre당 정확히 360000 EMU가 되도록 정의돼 있다. 사실상 이 두 상수가 EMU가 존재하는 이유 전부다. 914400은 2, 3, 4, 5, 6, 8, 9, 10, 12 등으로 깔끔하게 나뉘고, 소인수분해하면 26 × 32 × 52 × 127이다. 1 inch = 2.54 cm가 정확히 성립하므로, 360000과 914400의 여러 깔끔한 분수로 나누어떨어지는 단위를 쓰면, 형식은 inch, centimetre, point를 정수로 표현할 수 있다. 부동소수점으로 1.27 cm를 쓰면 드리프트가 생기지만, EMU에서는 457200을 저장하고 정확도를 잃지 않는다

여기서 또 중요한 단위는 point다. 조판 point는 1/72 inch이므로 1 point당 12700 EMU가 된다(914400 / 72). Point는 Excel이 행 높이, 글꼴 크기, 여백을 내부적으로 생각하는 방식이기도 하다. 그래서 텍스트 메트릭에 이미지를 맞추고 싶을 때 이미지 geometry를 point로 노출하는 것이 유용하다. HotXLS는 이 네 관계를 모두 라이브러리 상수로 인코딩한다

const
  XlsxEmuPerInch  = 914400;  // 1 inch
  XlsxEmuPerCm    = 360000;  // 1 centimetre
  XlsxEmuPerPoint = 12700;   // 1 point (1/72 inch)
  XlsxEmuPerPixel = 9525;    // 1 pixel at 96 DPI (914400 / 96)

마지막 줄이 바로 우표 크기 버그의 핵심이다. 픽셀은 DPI를 고정해야만 물리적 길이가 생기고, 9525 EMU는 96 DPI일 때의 픽셀 크기다. Excel의 기본 렌더링 DPI가 96이라서, 100-pixel 이미지는 기본 설정에서는 100 × 9525 = 952500 EMU, 즉 약 2.54 cm로 놓인다. 하지만 파일 안에는 소비자가 반드시 96 DPI를 써야 한다는 보장이 없다. 실제 단위로 작성하면 이 모호함이 사라진다. 4 cm는 화면이 96 DPI이든 220 DPI이든 4 cm다

TXLSXImage geometry surface

HotXLS에서 내장 그림은 TXLSXImage 객체다. 정식 저장 형태는 두 개의 정수 필드 WidthEMUHeightEMU 이며, 여기에 1-based RowCol 앵커가 붙어 있다. 즉 그림이 매달려 있는 좌상단 셀을 나타낸다. 실제 단위 property는 이 EMU 필드 위에 얹은 계산 view이며, 별도 상태가 아니다. WidthCM 을 읽으면 EMU를 360000으로 나누고, 거기에 값을 쓰면 다시 곱해서 반올림한 뒤 EMU로 저장한다. 결국 어떤 단위로 설정하든 바닥에는 같은 EMU 값 하나만 남는다

  • WidthInch / HeightInch 는 EMU를 914400으로 나눈 값이다
  • WidthCM / HeightCM 는 EMU를 360000으로 나눈 값이다
  • WidthPt / HeightPt 는 EMU를 12700으로 나눈 값이다
  • WidthEMU / HeightEMU 가 정수 기반의 source of truth다

이미지는 AddImage(ARow, ACol, AData, AFormat) 으로 추가한다. 인코딩된 raw byte와 TXLSXImageFormat 값(xlsxImagePng, xlsxImageJpeg, xlsxImageGif, xlsxImageBmp)을 넘기면, 워크시트의 Images 컬렉션 안에서의 zero-based 인덱스를 돌려준다. AddImageFromFile(ARow, ACol, AFileName) 도 있는데, 이쪽은 확장자로 format를 추론한다. 여기서 인덱스 기준에 주의해야 한다. AddImage 가 돌려주는 값도 zero-based이고 Images[] 도 zero-based다. 이는 1-based인 Cells[Row, Col] 그리드와 의도적으로 다르므로 둘이 같을 것이라고 가정하면 안 된다

var
  Sheet: TXLSXWorksheet;
  Img: TXLSXImage;
  Idx: Integer;
begin
  Sheet := Workbook.Sheets.Add('Images');

  // Anchor a PNG at row 3, column 2; AddImage returns a 0-based index.
  Idx := Sheet.AddImage(3, 2, LogoBytes, xlsxImagePng);

  Img := Sheet.Images[Idx];
  Img.WidthCM := 4.0;    // 4 cm wide  -> 1440000 EMU
  Img.HeightCM := 3.0;   // 3 cm tall  -> 1080000 EMU

  // Same geometry, read back in other units.
  // Img.WidthPt  is now 113.39 pt, Img.WidthInch is 1.5748 in.
end;

새로 만든 이미지는 기본적으로 100×100 pixel, 즉 952500 EMU 정사각형으로 시작한다. 이는 96 DPI 기준으로 약 2.54 cm 박스다. 이 기본값은 크기를 지정하지 않아도 그림이 보이게 하려는 안전장치일 뿐이다. 실제 레이아웃에서는 픽셀 유래 기본값에 기대지 말고, 반드시 물리적 크기를 명시해 두는 편이 좋다

Scaling과 aspect-ratio 플래그

현재 치수에 대한 상대 비율로 크기를 바꾸고 싶을 때, 예를 들어 가져온 차트 이미지를 현재 크기의 60%로 줄이고 싶을 때는 Scale 을 사용한다

procedure Scale(APercent: Double; AKeepAspect: Boolean = True);

APercent 는 퍼센트 값이다. 100은 변화 없음, 150은 1.5배 확대, 50은 절반 축소를 뜻한다. 기본값 TrueAKeepAspect 를 그대로 두면 너비와 높이에 같은 비율이 곱해지므로 비율이 유지된다. 4×3 cm 이미지는 Scale(150) 후 6×4.5 cm가 된다. False 를 넘기면 너비만 스케일되고 높이는 그대로 남는다. 이 비대칭성은 의도적이다. 두 축을 서로 독립적으로 늘이거나 줄이고 싶다면 올바른 도구는 명시적 WidthCM/HeightCM setter이며, Scale 의 비비율 branch는 너비만 조정하는 좁은 용도를 위해 존재한다. Scale(150, False) 를 "가로세로를 자유롭게 늘린다"고 읽으면 놀랄 수 있으므로, 진짜로 두 차원을 따로 만질 때는 setter를 사용하는 편이 낫다

Img.WidthCM := 4.0;
Img.HeightCM := 3.0;

Img.Scale(150);          // aspect locked: now 6.0 x 4.5 cm
Img.Scale(100);          // no-op, returns immediately

Img.Scale(50, False);    // width only: 3.0 cm wide, height unchanged at 4.5 cm

작지만 알아둘 동작이 하나 있다. Scale(100) 은 아무것도 바꾸지 않고 바로 반환하므로, 퍼센트가 100일 수도 있는 루프 안에서 조건 없이 호출해도 안전하다. 그리고 geometry는 정수 EMU로 저장되므로 모든 setter는 반올림을 수행한다. 따라서 소수 centimetre 값을 여러 번 왕복하면 극히 미세한 EMU 단위 드리프트가 생길 수 있다. 눈에 보일 수준은 아니지만, 테스트에서 정확한 동등성을 assert한다면 알아 둘 만하다. 픽셀 단위보다 더 엄격한 제어가 필요하다면 WidthEMUHeightEMU 를 직접 설정해 단위 변환을 건너뛰면 된다

Geometry를 다시 읽어 오기

이미지 컬렉션은 조회 가능하다. 기존 통합 문서를 로드한 뒤, 자신이 방금 추가한 그림이 아니라 이미 들어 있던 그림의 크기를 살펴보거나 조정해야 할 때 이 점이 중요하다. Images.Count 는 시트 위 모든 그림을 세고, Images[i] 는 zero-based 인덱스로 접근한다. FindAt(ARow, ACol) 는 특정 셀에 anchor된 그림을 찾아 반환하며, 없으면 nil 을 돌려준다. 인덱스가 필요할 때는 IndexOfCell 이 있고, 삭제는 DeleteAt 또는 DeleteInRange 로 할 수 있다

var
  i: Integer;
  Img: TXLSXImage;
begin
  for i := 0 to Sheet.Images.Count - 1 do
  begin
    Img := Sheet.Images[i];
    Writeln(Format('[%d] R%dC%d  %.2f x %.2f cm  (%d x %d EMU)',
      [i, Img.Row, Img.Col, Img.WidthCM, Img.HeightCM,
       Img.WidthEMU, Img.HeightEMU]));
  end;

  Img := Sheet.Images.FindAt(3, 2);   // nil-check before use
  if Img <> nil then
    Img.Scale(80);
end;

실제 단위 property는 live view이므로, 다른 도구가 EMU로 저장해 둔 그림을 가져오더라도 centimetre 기준 geometry를 즉시 읽어 낼 수 있다. 별도의 변환 단계는 필요 없다. 이 점은 더 넓은 drawing 모델과도 자연스럽게 이어진다. 차트와 shape까지 함께 배치한다면 HotXLS의 차트, 이미지, Excel drawing 가이드 가 이 객체들이 공유하는 anchor 모델을 설명한다

미터법 page-setup 여백

같은 EMU 대 현실 단위 문제는 한 단계 바깥인 페이지에도 나타난다. OOXML과 Excel은 인쇄 여백을 inch 단위로 저장한다. 미국 밖 대부분의 보고서 템플릿이 millimetre나 centimetre 기준으로 명세된다는 사실을 생각하면 꽤 불편하다. v2.91.0은 inch margin 위에 centimetre wrapper를 추가한다. MarginLeftCM, MarginRightCM, MarginTopCM, MarginBottomCM, MarginHeaderCM, MarginFooterCM 가 그것이다. 각각은 대응하는 inch property를 정확한 1 inch = 2.54 cm 비율로 감싼 얇은 convenience layer다

Sheet.MarginLeftCM := 2.0;     // 2 cm  == 0.7874 inch
Sheet.MarginRightCM := 2.0;
Sheet.MarginTopCM := 2.5;
Sheet.MarginBottomCM := 2.5;
Sheet.MarginHeaderCM := 1.0;
Sheet.MarginFooterCM := 1.0;

inch property인 MarginLeft 등은 여전히 실제 저장의 기준이므로, 두 표현을 섞어 써도 된다. 위쪽 여백은 centimetre로 설정하고 다시 inch로 읽을 수도 있고, 반대로도 가능하다. 어느 쪽을 쓰든 디스크에 기록되는 파일은 같다. 변환은 2.54를 곱하거나 나누는 단순한 계산이므로, 2 cm는 coarse grid로 깎이지 않고 double precision 수준까지 2 cm 그대로 유지된다. 이것도 이미지 geometry와 같은 철학이다. 형식 내부는 imperial을 말하지만, 라이브러리는 당신이 가진 사양서 단위로 작성할 수 있게 해 준다. 이 여백과 함께 제목, 메타데이터 블록, 합계 영역까지 레이아웃하는 방식은 병합 셀과 보고서 템플릿 레이아웃 글이 print area와 merge range를 결합해 보여 준다

이 geometry가 보장하는 것과 보장하지 않는 것

이 geometry property가 제어하는 것은 파일 안에 선언된 그림의 크기 다. conforming consumer가 렌더링할 때 사용할 크기이지, 이미지 바이트 자체를 다시 샘플링하는 기능은 아니다. 50×50 pixel PNG를 8 cm로 키우면, Excel에서 보이듯 당연히 확대되며 거칠어 보인다. 크기 지정은 레이아웃 작업이지 이미지 처리 작업이 아니다. 의도한 물리적 크기에 충분한 원본 해상도를 가진 그림을 넣어야 한다. 라이브러리는 format를 다시 인코딩하지도 않는다. AddImage 에 넘긴 바이트는 선언한 TXLSXImageFormat 과 함께 그대로 저장되고 다시 기록된다. JPEG 바이트를 넘기면서 format를 xlsxImagePng 로 태그하면 Excel이 열지 못하는 파일을 만들게 된다. 가능하다면 AddImageFromFile 로 확장자에서 format를 추론하게 두는 편이 안전하다

여기까지가 낯설어 보여도, 바닥에 있는 생각은 하나다. OOXML에서 진짜 양은 물리적 크기이고, 픽셀은 거기서 파생되는 DPI 의존적인 그림자에 불과하다. 이미지와 여백을 centimetre, inch, point 기준으로 작성하고, HotXLS가 그것을 정확한 EMU로 옮기게 두면, 송장과 보고서는 파일을 여는 모든 기계에서 같은 물리적 크기로 출력된다

여기서 설명한 이미지 geometry, scaling, metric-margin API는 Delphi와 C++Builder용 HotXLS Delphi spreadsheet component 에 포함되어 있으며, Excel 설치 없이도 XLS와 XLSX를 읽고 쓸 수 있다