├── .gitignore ├── src ├── utils │ ├── mod.rs │ ├── encoding.rs │ └── compression.rs ├── minimal_base_template.hwp ├── reader │ ├── mod.rs │ ├── cfb.rs │ └── stream.rs ├── render │ └── mod.rs ├── parser │ ├── mod.rs │ ├── doc_info.rs │ ├── header.rs │ ├── record.rs │ └── body_text.rs ├── error.rs ├── model │ ├── mod.rs │ ├── list_header.rs │ ├── tab_def.rs │ ├── style.rs │ ├── bin_data.rs │ ├── para_shape.rs │ ├── section_def.rs │ ├── ctrl_header.rs │ ├── para_char_shape.rs │ ├── char_shape.rs │ └── border_fill.rs ├── bin │ └── hwp_info.rs └── lib.rs ├── converted_output.hwp ├── .github └── workflows │ ├── docs.yml │ ├── release.yml │ └── ci.yml ├── examples ├── create_document.rs ├── verify_generated.rs ├── styled_text_document.rs ├── list_document.rs ├── styled_document.rs ├── paragraph_formatting.rs ├── verify_roundtrip.rs ├── hyperlink_document.rs ├── table_document.rs ├── image_document.rs ├── debug_records.rs ├── text_box_document.rs ├── analyze_single_hyperlink.rs ├── hyperlink_reverse_engineer.rs ├── page_layout_document.rs └── complete_feature_demo.rs ├── Cargo.toml ├── LICENSE ├── tests ├── integration_test.rs ├── writer_test.rs ├── style_test.rs ├── serialization_test.rs ├── roundtrip_test.rs ├── list_test.rs ├── styled_text_test.rs ├── table_test.rs └── advanced_table_test.rs ├── scripts └── test-workflows.sh ├── CLAUDE.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | test-files 3 | 4 | .serena 5 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod compression; 2 | pub mod encoding; 3 | -------------------------------------------------------------------------------- /converted_output.hwp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Indosaram/hwpers/HEAD/converted_output.hwp -------------------------------------------------------------------------------- /src/minimal_base_template.hwp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Indosaram/hwpers/HEAD/src/minimal_base_template.hwp -------------------------------------------------------------------------------- /src/reader/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cfb; 2 | pub mod stream; 3 | 4 | pub use self::cfb::CfbReader; 5 | pub use self::stream::StreamReader; 6 | -------------------------------------------------------------------------------- /src/render/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod layout; 2 | pub mod renderer; 3 | 4 | pub use layout::{LayoutEngine, LayoutResult, RenderedPage}; 5 | pub use renderer::{HwpRenderer, RenderOptions}; 6 | -------------------------------------------------------------------------------- /src/parser/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod body_text; 2 | pub mod doc_info; 3 | pub mod header; 4 | pub mod record; 5 | 6 | pub use self::header::FileHeader; 7 | pub use self::record::{HwpTag, Record, RecordHeader}; 8 | -------------------------------------------------------------------------------- /src/utils/encoding.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{HwpError, Result}; 2 | use encoding_rs::UTF_16LE; 3 | 4 | pub fn utf16le_to_string(data: &[u8]) -> Result { 5 | let (cow, _, had_errors) = UTF_16LE.decode(data); 6 | if had_errors { 7 | return Err(HwpError::EncodingError("Invalid UTF-16LE data".to_string())); 8 | } 9 | Ok(cow.into_owned()) 10 | } 11 | 12 | pub fn string_to_utf16le(s: &str) -> Vec { 13 | let mut result = Vec::new(); 14 | for ch in s.encode_utf16() { 15 | result.extend_from_slice(&ch.to_le_bytes()); 16 | } 17 | result 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Documentation 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | docs: 14 | name: Documentation 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: dtolnay/rust-toolchain@stable 19 | - uses: Swatinem/rust-cache@v2 20 | - name: Build documentation 21 | run: cargo doc --no-deps --all-features 22 | - name: Check documentation links 23 | run: cargo doc --no-deps --all-features 24 | env: 25 | RUSTDOCFLAGS: "-D warnings" -------------------------------------------------------------------------------- /examples/create_document.rs: -------------------------------------------------------------------------------- 1 | use hwpers::HwpWriter; 2 | 3 | fn main() -> Result<(), Box> { 4 | println!("Creating new HWP document...\n"); 5 | 6 | // Create a new HWP writer 7 | let mut writer = HwpWriter::new(); 8 | 9 | // Add content 10 | writer.add_paragraph("안녕하세요! 이것은 hwpers 라이브러리로 만든 HWP 문서입니다.")?; 11 | writer.add_paragraph("")?; // Empty paragraph for spacing 12 | writer.add_paragraph("This document was created using the hwpers Rust library.")?; 13 | writer.add_paragraph("It should open correctly in Hangul word processor.")?; 14 | 15 | // Save to file 16 | writer.save_to_file("example_document.hwp")?; 17 | 18 | println!("✅ Created example_document.hwp"); 19 | println!("This file can be opened in Hangul word processor."); 20 | 21 | Ok(()) 22 | } 23 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum HwpError { 5 | #[error("Invalid file format: {0}")] 6 | InvalidFormat(String), 7 | 8 | #[error("Unsupported version: {0}")] 9 | UnsupportedVersion(String), 10 | 11 | #[error("IO error: {0}")] 12 | Io(#[from] std::io::Error), 13 | 14 | #[error("CFB error: {0}")] 15 | Cfb(String), 16 | 17 | #[error("Compression error: {0}")] 18 | CompressionError(String), 19 | 20 | #[error("Parse error: {0}")] 21 | ParseError(String), 22 | 23 | #[error("Encoding error: {0}")] 24 | EncodingError(String), 25 | 26 | #[error("Not found: {0}")] 27 | NotFound(String), 28 | 29 | #[error("Invalid input: {0}")] 30 | InvalidInput(String), 31 | } 32 | 33 | pub type Result = std::result::Result; 34 | -------------------------------------------------------------------------------- /src/utils/compression.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use flate2::read::ZlibDecoder; 3 | use std::io::Read; 4 | 5 | pub fn decompress_stream(data: &[u8]) -> Result> { 6 | if data.is_empty() { 7 | return Ok(Vec::new()); 8 | } 9 | 10 | // HWP files use raw deflate without zlib header 11 | // Try raw deflate first 12 | use flate2::read::DeflateDecoder; 13 | let mut decoder = DeflateDecoder::new(data); 14 | let mut decompressed = Vec::new(); 15 | 16 | match decoder.read_to_end(&mut decompressed) { 17 | Ok(_) => Ok(decompressed), 18 | Err(_) => { 19 | // If raw deflate fails, try zlib 20 | let mut decoder = ZlibDecoder::new(data); 21 | let mut decompressed = Vec::new(); 22 | 23 | match decoder.read_to_end(&mut decompressed) { 24 | Ok(_) => Ok(decompressed), 25 | Err(_) => { 26 | // If both fail, return data as-is (might not be compressed) 27 | Ok(data.to_vec()) 28 | } 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hwpers" 3 | version = "0.3.1" 4 | edition = "2021" 5 | authors = ["HWP Parser Contributors"] 6 | description = "A Rust library for parsing Korean Hangul Word Processor (HWP) files with full layout rendering support" 7 | license = "MIT OR Apache-2.0" 8 | repository = "https://github.com/Indosaram/hwpers" 9 | documentation = "https://docs.rs/hwpers" 10 | homepage = "https://github.com/Indosaram/hwpers" 11 | readme = "README.md" 12 | keywords = ["hwp", "parser", "hangul", "document", "korean"] 13 | categories = ["parser-implementations", "text-processing", "rendering"] 14 | include = [ 15 | "src/**/*", 16 | "Cargo.toml", 17 | "README.md", 18 | "LICENSE-*", 19 | ] 20 | 21 | [dependencies] 22 | cfb = "0.11.0" 23 | flate2 = "1.0" 24 | encoding_rs = "0.8" 25 | byteorder = "1.5" 26 | thiserror = "1.0" 27 | chrono = { version = "0.4", features = ["serde"] } 28 | serde = { version = "1.0", features = ["derive"], optional = true } 29 | 30 | [dev-dependencies] 31 | pretty_assertions = "1.4" 32 | tempfile = "3.8" 33 | 34 | [features] 35 | default = [] 36 | serde_support = ["serde"] 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 HWP Parser Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /examples/verify_generated.rs: -------------------------------------------------------------------------------- 1 | // Verify that generated HWP files can be read back 2 | use hwpers::HwpReader; 3 | 4 | fn main() -> Result<(), Box> { 5 | let files = [ 6 | "complete_feature_demo.hwp", 7 | "hyperlink_example.hwp", 8 | "header_footer_example.hwp", 9 | "alignment_example.hwp", 10 | "spacing_example.hwp", 11 | ]; 12 | 13 | for file in &files { 14 | println!("\n=== Verifying {} ===", file); 15 | 16 | match HwpReader::from_file(file) { 17 | Ok(doc) => { 18 | println!("✅ Successfully parsed"); 19 | 20 | // Extract and show text 21 | let text = doc.extract_text(); 22 | let preview = if text.chars().count() > 50 { 23 | let truncated: String = text.chars().take(50).collect(); 24 | format!("{}...", truncated) 25 | } else { 26 | text.clone() 27 | }; 28 | println!("Text preview: {}", preview); 29 | 30 | // Show document structure 31 | println!("Sections: {}", doc.sections().count()); 32 | 33 | let total_paragraphs: usize = doc.sections().map(|s| s.paragraphs.len()).sum(); 34 | println!("Total paragraphs: {}", total_paragraphs); 35 | } 36 | Err(e) => { 37 | println!("❌ Failed to parse: {}", e); 38 | } 39 | } 40 | } 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /src/reader/cfb.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{HwpError, Result}; 2 | use cfb::CompoundFile; 3 | use std::io::{Read, Seek}; 4 | use std::path::Path; 5 | 6 | pub struct CfbReader { 7 | cfb: CompoundFile, 8 | } 9 | 10 | impl CfbReader { 11 | pub fn from_file>(path: P) -> Result { 12 | let file = std::fs::File::open(path)?; 13 | let cfb = CompoundFile::open(file) 14 | .map_err(|e| HwpError::Cfb(format!("Failed to open CFB: {e}")))?; 15 | Ok(Self { cfb }) 16 | } 17 | } 18 | 19 | impl CfbReader { 20 | pub fn new(reader: F) -> Result { 21 | let cfb = CompoundFile::open(reader) 22 | .map_err(|e| HwpError::Cfb(format!("Failed to open CFB: {e}")))?; 23 | Ok(Self { cfb }) 24 | } 25 | 26 | pub fn read_stream(&mut self, path: &str) -> Result> { 27 | let mut stream = self 28 | .cfb 29 | .open_stream(path) 30 | .map_err(|e| HwpError::NotFound(format!("Stream '{path}' not found: {e}")))?; 31 | 32 | let mut buffer = Vec::new(); 33 | stream.read_to_end(&mut buffer)?; 34 | Ok(buffer) 35 | } 36 | 37 | pub fn stream_exists(&self, path: &str) -> bool { 38 | self.cfb.exists(path) 39 | } 40 | 41 | pub fn list_streams(&self) -> Vec { 42 | let mut streams = Vec::new(); 43 | for entry in self.cfb.walk() { 44 | if entry.is_stream() { 45 | streams.push(entry.path().display().to_string()); 46 | } 47 | } 48 | streams 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod bin_data; 2 | pub mod border_fill; 3 | pub mod char_shape; 4 | pub mod control; 5 | pub mod ctrl_header; 6 | pub mod document; 7 | pub mod header_footer; 8 | pub mod hyperlink; 9 | pub mod list_header; 10 | pub mod numbering; 11 | pub mod page_def; 12 | pub mod page_layout; 13 | pub mod para_char_shape; 14 | pub mod para_line_seg; 15 | pub mod para_shape; 16 | pub mod paragraph; 17 | pub mod section_def; 18 | pub mod style; 19 | pub mod tab_def; 20 | pub mod text_box; 21 | 22 | pub use self::char_shape::{CharShape, FaceName}; 23 | pub use self::control::{Control, Table, TableCell}; 24 | pub use self::ctrl_header::{ControlType, CtrlHeader}; 25 | pub use self::document::{DocumentProperties, FormattedText, HwpDocument}; 26 | pub use self::header_footer::{ 27 | HeaderFooter, HeaderFooterAlignment, HeaderFooterCollection, HeaderFooterType, PageApplyType, 28 | PageNumberFormat, 29 | }; 30 | pub use self::hyperlink::{Hyperlink, HyperlinkDisplay, HyperlinkType}; 31 | pub use self::list_header::ListHeader; 32 | pub use self::page_def::PageDef; 33 | pub use self::page_layout::{ 34 | hwp_units_to_inches, hwp_units_to_mm, inches_to_hwp_units, mm_to_hwp_units, MarginUnit, 35 | PageLayout, PageMargins, PageOrientation, PaperSize, 36 | }; 37 | pub use self::para_char_shape::{CharPositionShape, ParaCharShape}; 38 | pub use self::para_line_seg::{LineSegment, ParaLineSeg}; 39 | pub use self::para_shape::ParaShape; 40 | pub use self::paragraph::{ParaText, Paragraph, Section}; 41 | pub use self::section_def::SectionDef; 42 | pub use self::text_box::{TextBox, TextBoxAlignment, TextBoxBorderStyle, TextBoxFillType}; 43 | -------------------------------------------------------------------------------- /src/model/list_header.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::parser::record::Record; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct ListHeader { 6 | pub paragraph_count: i32, 7 | pub properties: u32, 8 | pub text_width: i32, 9 | pub text_height: i32, 10 | pub padding: [u8; 8], 11 | } 12 | 13 | impl ListHeader { 14 | pub fn from_record(record: &Record) -> Result { 15 | let mut reader = record.data_reader(); 16 | 17 | if reader.remaining() < 20 { 18 | return Err(crate::error::HwpError::ParseError(format!( 19 | "ListHeader record too small: {} bytes", 20 | reader.remaining() 21 | ))); 22 | } 23 | 24 | let paragraph_count = reader.read_i32()?; 25 | let properties = reader.read_u32()?; 26 | let text_width = reader.read_i32()?; 27 | let text_height = reader.read_i32()?; 28 | 29 | let mut padding = [0u8; 8]; 30 | if reader.remaining() >= 8 { 31 | for item in &mut padding { 32 | *item = reader.read_u8()?; 33 | } 34 | } 35 | 36 | Ok(Self { 37 | paragraph_count, 38 | properties, 39 | text_width, 40 | text_height, 41 | padding, 42 | }) 43 | } 44 | 45 | pub fn is_multi_column(&self) -> bool { 46 | (self.properties & 0x01) != 0 47 | } 48 | 49 | pub fn has_line_wrap(&self) -> bool { 50 | (self.properties & 0x02) != 0 51 | } 52 | 53 | pub fn is_editable_at_form_mode(&self) -> bool { 54 | (self.properties & 0x04) != 0 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | env: 9 | CARGO_TERM_COLOR: always 10 | 11 | permissions: 12 | contents: write 13 | 14 | jobs: 15 | create_release: 16 | name: Create Release 17 | runs-on: ubuntu-latest 18 | outputs: 19 | upload_url: ${{ steps.create_release.outputs.upload_url }} 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | - name: Create Release 24 | id: create_release 25 | uses: softprops/action-gh-release@v1 26 | with: 27 | tag_name: ${{ github.ref }} 28 | name: Release ${{ github.ref_name }} 29 | draft: false 30 | prerelease: false 31 | generate_release_notes: true 32 | 33 | test: 34 | name: Test before release 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v4 38 | - uses: dtolnay/rust-toolchain@stable 39 | - uses: Swatinem/rust-cache@v2 40 | - name: Run tests 41 | run: cargo test --all-features 42 | - name: Check formatting 43 | run: cargo fmt --check 44 | - name: Run clippy 45 | run: cargo clippy --all-features --all-targets -- -D warnings 46 | 47 | publish: 48 | name: Publish to crates.io 49 | runs-on: ubuntu-latest 50 | needs: [create_release, test] 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: dtolnay/rust-toolchain@stable 54 | - uses: Swatinem/rust-cache@v2 55 | - name: Login to crates.io 56 | run: cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} 57 | - name: Publish to crates.io 58 | run: cargo publish --all-features -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main, master ] 6 | pull_request: 7 | branches: [ main, master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | rust: 19 | - stable 20 | - beta 21 | - nightly 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: dtolnay/rust-toolchain@master 25 | with: 26 | toolchain: ${{ matrix.rust }} 27 | - uses: Swatinem/rust-cache@v2 28 | - name: Run tests 29 | run: cargo test --all-features 30 | 31 | fmt: 32 | name: Rustfmt 33 | runs-on: ubuntu-latest 34 | steps: 35 | - uses: actions/checkout@v4 36 | - uses: dtolnay/rust-toolchain@stable 37 | with: 38 | components: rustfmt 39 | - name: Enforce formatting 40 | run: cargo fmt --check 41 | 42 | clippy: 43 | name: Clippy 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | - uses: dtolnay/rust-toolchain@stable 48 | with: 49 | components: clippy 50 | - uses: Swatinem/rust-cache@v2 51 | - name: Linting 52 | run: cargo clippy --all-features --all-targets -- -D warnings 53 | 54 | security_audit: 55 | name: Security audit 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | - uses: rustsec/audit-check@v1.4.1 60 | with: 61 | token: ${{ secrets.GITHUB_TOKEN }} 62 | 63 | check: 64 | name: Check 65 | runs-on: ubuntu-latest 66 | steps: 67 | - uses: actions/checkout@v4 68 | - uses: dtolnay/rust-toolchain@stable 69 | - uses: Swatinem/rust-cache@v2 70 | - name: Check 71 | run: cargo check --all-features -------------------------------------------------------------------------------- /tests/integration_test.rs: -------------------------------------------------------------------------------- 1 | use hwpers::HwpReader; 2 | use std::path::PathBuf; 3 | 4 | fn test_file_path(name: &str) -> PathBuf { 5 | PathBuf::from("test-files").join(name) 6 | } 7 | 8 | #[test] 9 | fn test_basic_parsing() { 10 | let path = test_file_path("test_document.hwp"); 11 | if !path.exists() { 12 | eprintln!("Skipping test: test file not found at {path:?}"); 13 | return; 14 | } 15 | 16 | let doc = HwpReader::from_file(path).expect("Failed to parse HWP file"); 17 | 18 | // Verify header 19 | assert!(!doc.header.is_encrypted()); 20 | 21 | // Verify we have at least one section 22 | assert!(doc.sections().count() > 0); 23 | } 24 | 25 | #[test] 26 | fn test_text_extraction() { 27 | let path = test_file_path("test_document.hwp"); 28 | if !path.exists() { 29 | eprintln!("Skipping test: test file not found at {path:?}"); 30 | return; 31 | } 32 | 33 | let doc = HwpReader::from_file(path).expect("Failed to parse HWP file"); 34 | let text = doc.extract_text(); 35 | 36 | // For now, we'll just verify the parser works even if text extraction is incomplete 37 | println!("Extracted text length: {} characters", text.len()); 38 | 39 | // This is a minimal test - just verify the parser doesn't crash 40 | let _ = doc.sections().count(); // Verify we can iterate sections 41 | } 42 | 43 | #[test] 44 | fn test_from_bytes() { 45 | let path = test_file_path("test_document.hwp"); 46 | if !path.exists() { 47 | eprintln!("Skipping test: test file not found at {path:?}"); 48 | return; 49 | } 50 | 51 | let bytes = std::fs::read(&path).expect("Failed to read file"); 52 | let doc = HwpReader::from_bytes(&bytes).expect("Failed to parse HWP file"); 53 | 54 | // Verify we can parse from bytes 55 | assert!(doc.sections().count() > 0); 56 | } 57 | -------------------------------------------------------------------------------- /src/reader/stream.rs: -------------------------------------------------------------------------------- 1 | use crate::error::{HwpError, Result}; 2 | use byteorder::{LittleEndian, ReadBytesExt}; 3 | use std::io::{Cursor, Read}; 4 | 5 | pub struct StreamReader { 6 | cursor: Cursor>, 7 | } 8 | 9 | impl StreamReader { 10 | pub fn new(data: Vec) -> Self { 11 | Self { 12 | cursor: Cursor::new(data), 13 | } 14 | } 15 | 16 | pub fn read_u8(&mut self) -> Result { 17 | Ok(self.cursor.read_u8()?) 18 | } 19 | 20 | pub fn read_u16(&mut self) -> Result { 21 | Ok(self.cursor.read_u16::()?) 22 | } 23 | 24 | pub fn read_u32(&mut self) -> Result { 25 | Ok(self.cursor.read_u32::()?) 26 | } 27 | 28 | pub fn read_i32(&mut self) -> Result { 29 | Ok(self.cursor.read_i32::()?) 30 | } 31 | 32 | pub fn read_bytes(&mut self, len: usize) -> Result> { 33 | let mut buffer = vec![0u8; len]; 34 | self.cursor.read_exact(&mut buffer)?; 35 | Ok(buffer) 36 | } 37 | 38 | pub fn read_string(&mut self, len: usize) -> Result { 39 | let bytes = self.read_bytes(len)?; 40 | let (cow, _, had_errors) = encoding_rs::UTF_16LE.decode(&bytes); 41 | if had_errors { 42 | return Err(HwpError::EncodingError( 43 | "Invalid UTF-16LE string".to_string(), 44 | )); 45 | } 46 | Ok(cow.into_owned().trim_end_matches('\0').to_string()) 47 | } 48 | 49 | pub fn position(&self) -> u64 { 50 | self.cursor.position() 51 | } 52 | 53 | pub fn set_position(&mut self, pos: u64) { 54 | self.cursor.set_position(pos); 55 | } 56 | 57 | pub fn remaining(&self) -> usize { 58 | let pos = self.cursor.position() as usize; 59 | let len = self.cursor.get_ref().len(); 60 | len.saturating_sub(pos) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /scripts/test-workflows.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Script to test GitHub Actions workflows locally 3 | 4 | echo "🧪 Testing CI Workflow Steps..." 5 | echo "================================" 6 | 7 | echo "1️⃣ Running tests..." 8 | cargo test --all-features 9 | if [ $? -ne 0 ]; then 10 | echo "❌ Tests failed" 11 | exit 1 12 | fi 13 | 14 | echo -e "\n2️⃣ Checking formatting..." 15 | cargo fmt --check 16 | if [ $? -ne 0 ]; then 17 | echo "❌ Formatting check failed" 18 | echo "Run 'cargo fmt' to fix formatting issues" 19 | exit 1 20 | fi 21 | 22 | echo -e "\n3️⃣ Running clippy..." 23 | cargo clippy --all-features --all-targets -- -D warnings 24 | if [ $? -ne 0 ]; then 25 | echo "❌ Clippy check failed" 26 | exit 1 27 | fi 28 | 29 | echo -e "\n4️⃣ Running security audit..." 30 | cargo audit 31 | if [ $? -ne 0 ]; then 32 | echo "❌ Security audit failed" 33 | exit 1 34 | fi 35 | 36 | echo -e "\n5️⃣ Running cargo check..." 37 | cargo check --all-features 38 | if [ $? -ne 0 ]; then 39 | echo "❌ Cargo check failed" 40 | exit 1 41 | fi 42 | 43 | echo -e "\n🎉 All CI checks passed!" 44 | 45 | echo -e "\n📦 Testing Release Workflow Steps..." 46 | echo "====================================" 47 | 48 | echo "1️⃣ Building release..." 49 | cargo build --release 50 | if [ $? -ne 0 ]; then 51 | echo "❌ Release build failed" 52 | exit 1 53 | fi 54 | 55 | echo -e "\n2️⃣ Running release tests..." 56 | cargo test --release 57 | if [ $? -ne 0 ]; then 58 | echo "❌ Release tests failed" 59 | exit 1 60 | fi 61 | 62 | echo -e "\n3️⃣ Creating package (dry run)..." 63 | cargo package --allow-dirty --list > /dev/null 2>&1 64 | if [ $? -ne 0 ]; then 65 | echo "❌ Package creation failed" 66 | exit 1 67 | fi 68 | 69 | echo -e "\n✅ All workflow tests passed!" 70 | echo -e "\n📝 Next steps to publish:" 71 | echo "1. Update repository URL in Cargo.toml" 72 | echo "2. Add CARGO_REGISTRY_TOKEN secret to GitHub repository" 73 | echo "3. Commit and push all changes" 74 | echo "4. Create and push a version tag: git tag v0.1.0 && git push origin v0.1.0" -------------------------------------------------------------------------------- /src/model/tab_def.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::parser::record::Record; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct TabDef { 6 | pub properties: u32, 7 | pub tabs: Vec, 8 | } 9 | 10 | #[derive(Debug, Clone)] 11 | pub struct Tab { 12 | pub position: u32, 13 | pub tab_type: u8, 14 | pub leader_type: u8, 15 | } 16 | 17 | impl TabDef { 18 | pub fn from_record(record: &Record) -> Result { 19 | let mut reader = record.data_reader(); 20 | 21 | if reader.remaining() < 4 { 22 | return Err(crate::error::HwpError::ParseError(format!( 23 | "TabDef record too small: {} bytes", 24 | reader.remaining() 25 | ))); 26 | } 27 | 28 | let properties = reader.read_u32()?; 29 | let mut tabs = Vec::new(); 30 | 31 | // Each tab entry is 6 bytes (4 bytes position + 1 byte type + 1 byte leader) 32 | while reader.remaining() >= 6 { 33 | let tab = Tab { 34 | position: reader.read_u32()?, 35 | tab_type: reader.read_u8()?, 36 | leader_type: reader.read_u8()?, 37 | }; 38 | tabs.push(tab); 39 | } 40 | 41 | Ok(Self { properties, tabs }) 42 | } 43 | } 44 | impl TabDef { 45 | /// Create a new default TabDef for writing 46 | pub fn new_default() -> Self { 47 | // Create standard tab stops every 20mm (5669 HWP units) 48 | let mut tabs = Vec::new(); 49 | for i in 1..=10 { 50 | tabs.push(Tab { 51 | position: i * 5669, // 20mm intervals 52 | tab_type: 0, // Left tab 53 | leader_type: 0, // No leader 54 | }); 55 | } 56 | 57 | Self { 58 | properties: 0, 59 | tabs, 60 | } 61 | } 62 | } 63 | 64 | impl Tab { 65 | pub fn is_left_aligned(&self) -> bool { 66 | self.tab_type & 0x03 == 0 67 | } 68 | 69 | pub fn is_center_aligned(&self) -> bool { 70 | self.tab_type & 0x03 == 1 71 | } 72 | 73 | pub fn is_right_aligned(&self) -> bool { 74 | self.tab_type & 0x03 == 2 75 | } 76 | 77 | pub fn is_decimal_aligned(&self) -> bool { 78 | self.tab_type & 0x03 == 3 79 | } 80 | 81 | pub fn has_leader(&self) -> bool { 82 | self.leader_type != 0 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/bin/hwp_info.rs: -------------------------------------------------------------------------------- 1 | use hwpers::HwpReader; 2 | use std::env; 3 | use std::path::Path; 4 | 5 | fn main() { 6 | let args: Vec = env::args().collect(); 7 | 8 | if args.len() != 2 { 9 | eprintln!("Usage: {} ", args[0]); 10 | std::process::exit(1); 11 | } 12 | 13 | let file_path = Path::new(&args[1]); 14 | 15 | if !file_path.exists() { 16 | eprintln!("File not found: {file_path:?}"); 17 | std::process::exit(1); 18 | } 19 | 20 | // First, try to read as CFB 21 | match hwpers::reader::CfbReader::from_file(file_path) { 22 | Ok(mut reader) => { 23 | println!("CFB structure detected!"); 24 | println!("Available streams:"); 25 | for stream in reader.list_streams() { 26 | println!(" - {stream}"); 27 | } 28 | 29 | // Try to read FileHeader 30 | match reader.read_stream("FileHeader") { 31 | Ok(data) => { 32 | println!("\nFileHeader stream: {} bytes", data.len()); 33 | if data.len() >= 32 { 34 | let signature = String::from_utf8_lossy(&data[..32]); 35 | println!("Signature: {signature:?}"); 36 | } 37 | } 38 | Err(e) => { 39 | println!("Error reading FileHeader: {e}"); 40 | } 41 | } 42 | } 43 | Err(e) => { 44 | eprintln!("Not a valid CFB file: {e}"); 45 | std::process::exit(1); 46 | } 47 | } 48 | 49 | println!("\nTrying full parse..."); 50 | match HwpReader::from_file(file_path) { 51 | Ok(doc) => { 52 | println!("Successfully parsed!"); 53 | println!("Version: {}", doc.header.version_string()); 54 | println!("Compressed: {}", doc.header.is_compressed()); 55 | let section_count = doc.sections().count(); 56 | println!("Sections: {section_count}"); 57 | 58 | if section_count == 0 { 59 | println!("\nNo sections found. Body texts: {}", doc.body_texts.len()); 60 | for (i, bt) in doc.body_texts.iter().enumerate() { 61 | println!(" BodyText {}: {} sections", i, bt.sections.len()); 62 | } 63 | } 64 | } 65 | Err(e) => { 66 | println!("Parse error: {e}"); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/writer_test.rs: -------------------------------------------------------------------------------- 1 | use hwpers::{HwpReader, HwpWriter}; 2 | 3 | #[test] 4 | fn test_basic_writer_creation() { 5 | let writer = HwpWriter::new(); 6 | assert!(writer.to_bytes().is_ok()); 7 | } 8 | 9 | #[test] 10 | fn test_add_single_paragraph() { 11 | let mut writer = HwpWriter::new(); 12 | assert!(writer.add_paragraph("안녕하세요").is_ok()); 13 | assert!(writer.to_bytes().is_ok()); 14 | } 15 | 16 | #[test] 17 | fn test_add_multiple_paragraphs() { 18 | let mut writer = HwpWriter::new(); 19 | assert!(writer.add_paragraph("첫 번째 문단").is_ok()); 20 | assert!(writer.add_paragraph("두 번째 문단").is_ok()); 21 | assert!(writer.add_paragraph("세 번째 문단").is_ok()); 22 | assert!(writer.to_bytes().is_ok()); 23 | } 24 | 25 | #[test] 26 | fn test_save_to_file() { 27 | let mut writer = HwpWriter::new(); 28 | writer.add_paragraph("테스트 문서").unwrap(); 29 | 30 | let temp_path = "test_output.hwp"; 31 | assert!(writer.save_to_file(temp_path).is_ok()); 32 | 33 | // Clean up 34 | if std::path::Path::new(temp_path).exists() { 35 | std::fs::remove_file(temp_path).ok(); 36 | } 37 | } 38 | 39 | #[test] 40 | fn test_write_read_roundtrip() { 41 | let mut writer = HwpWriter::new(); 42 | writer.add_paragraph("Hello World").unwrap(); 43 | writer.add_paragraph("안녕하세요").unwrap(); 44 | 45 | let bytes = writer.to_bytes().unwrap(); 46 | assert!(!bytes.is_empty()); 47 | 48 | // Try to parse the generated bytes with HwpReader 49 | // Note: This might fail initially as our CFB generation is simplified 50 | let parse_result = HwpReader::from_bytes(&bytes); 51 | 52 | // For now, we just verify that the writer produces some output 53 | // In the future, we can verify the reader can parse it correctly 54 | println!("Generated {} bytes", bytes.len()); 55 | println!("Reader parse result: {:?}", parse_result.is_ok()); 56 | } 57 | 58 | #[test] 59 | fn test_empty_document() { 60 | let writer = HwpWriter::new(); 61 | let bytes = writer.to_bytes().unwrap(); 62 | assert!(!bytes.is_empty()); 63 | println!("Empty document size: {} bytes", bytes.len()); 64 | } 65 | 66 | #[test] 67 | fn test_korean_text() { 68 | let mut writer = HwpWriter::new(); 69 | writer.add_paragraph("한글 문서 테스트").unwrap(); 70 | writer.add_paragraph("韓國語 漢字 混用").unwrap(); 71 | writer.add_paragraph("English mixed text").unwrap(); 72 | 73 | let bytes = writer.to_bytes().unwrap(); 74 | assert!(!bytes.is_empty()); 75 | println!("Mixed language document size: {} bytes", bytes.len()); 76 | } 77 | -------------------------------------------------------------------------------- /src/model/style.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::parser::record::Record; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct Style { 6 | pub name: String, 7 | pub english_name: String, 8 | pub properties: u8, 9 | pub next_style_id: u8, 10 | pub lang_id: u16, 11 | pub para_shape_id: u16, 12 | pub char_shape_id: u16, 13 | } 14 | 15 | impl Style { 16 | pub fn from_record(record: &Record) -> Result { 17 | let mut reader = record.data_reader(); 18 | 19 | if reader.remaining() < 10 { 20 | return Err(crate::error::HwpError::ParseError(format!( 21 | "Style record too small: {} bytes", 22 | reader.remaining() 23 | ))); 24 | } 25 | 26 | // Read name length and name 27 | let name_len = reader.read_u16()? as usize; 28 | if reader.remaining() < name_len * 2 { 29 | return Err(crate::error::HwpError::ParseError( 30 | "Invalid style name length".to_string(), 31 | )); 32 | } 33 | let name = reader.read_string(name_len * 2)?; 34 | 35 | // Read English name length and name 36 | let english_name = if reader.remaining() >= 2 { 37 | let english_name_len = reader.read_u16()? as usize; 38 | if reader.remaining() >= english_name_len * 2 { 39 | reader.read_string(english_name_len * 2)? 40 | } else { 41 | "Normal".to_string() 42 | } 43 | } else { 44 | "Normal".to_string() 45 | }; 46 | 47 | // Read remaining fields 48 | if reader.remaining() < 8 { 49 | return Err(crate::error::HwpError::ParseError( 50 | "Insufficient data for style properties".to_string(), 51 | )); 52 | } 53 | 54 | let properties = reader.read_u8()?; 55 | let next_style_id = reader.read_u8()?; 56 | let lang_id = reader.read_u16()?; 57 | let para_shape_id = reader.read_u16()?; 58 | let char_shape_id = reader.read_u16()?; 59 | 60 | Ok(Self { 61 | name, 62 | english_name, 63 | properties, 64 | next_style_id, 65 | lang_id, 66 | para_shape_id, 67 | char_shape_id, 68 | }) 69 | } 70 | } 71 | impl Style { 72 | /// Create a new default Style for writing 73 | pub fn new_default() -> Self { 74 | Self { 75 | name: "바탕글".to_string(), 76 | english_name: "Normal".to_string(), 77 | properties: 0, 78 | next_style_id: 0, 79 | lang_id: 0x0412, // Korean language ID 80 | para_shape_id: 0, 81 | char_shape_id: 0, 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /examples/styled_text_document.rs: -------------------------------------------------------------------------------- 1 | use hwpers::writer::style::{StyledText, TextStyle}; 2 | use hwpers::{error::Result, HwpWriter}; 3 | 4 | fn main() -> Result<()> { 5 | let mut writer = HwpWriter::new(); 6 | 7 | // Add title with basic styling 8 | writer.add_heading("한글 텍스트 서식 예제", 1)?; 9 | 10 | // Add paragraph with simple bold text 11 | writer.add_paragraph_with_bold( 12 | "이 문장에서 굵게 처리된 텍스트가 있습니다.", 13 | vec![(9, 15)], // "굵게 처리된" 부분을 굵게 14 | )?; 15 | 16 | // Add paragraph with multiple colors 17 | writer.add_paragraph_with_colors( 18 | "빨간색 텍스트와 파란색 텍스트가 포함된 문장입니다.", 19 | vec![ 20 | (0, 5, 0xFF0000), // "빨간색" - 빨간색 21 | (11, 16, 0x0000FF), // "파란색" - 파란색 22 | ], 23 | )?; 24 | 25 | // Add paragraph with highlighting 26 | writer.add_paragraph_with_highlight( 27 | "이 문장에는 노란색 하이라이트가 적용됩니다.", 28 | vec![(9, 14, 0xFFFF00)], // "노란색 하이라이트" 부분을 노란색으로 하이라이트 29 | )?; 30 | 31 | // Add complex styled text using StyledText builder 32 | let styled_text = StyledText::new("복잡한 서식이 적용된 텍스트입니다.".to_string()) 33 | .add_range(0, 3, TextStyle::new().bold().color(0xFF0000)) // "복잡한" - 빨간색 굵게 34 | .add_range(4, 6, TextStyle::new().italic().color(0x00FF00)) // "서식이" - 녹색 기울임 35 | .add_range(7, 9, TextStyle::new().underline().color(0x0000FF)) // "적용된" - 파란색 밑줄 36 | .add_range(10, 13, TextStyle::new().size(16).background(0xFFFF00)); // "텍스트입니다" - 16pt 노란 배경 37 | 38 | writer.add_styled_paragraph(&styled_text)?; 39 | 40 | // Add paragraph with font changes 41 | let font_text = StyledText::new("다양한 폰트가 사용된 문장입니다.".to_string()) 42 | .add_range(0, 3, TextStyle::new().font("Arial").size(14)) // "다양한" - Arial 14pt 43 | .add_range(4, 6, TextStyle::new().font("Times New Roman").size(12)) // "폰트가" - Times 12pt 44 | .add_range(7, 9, TextStyle::new().font("Courier New").size(10)); // "사용된" - Courier 10pt 45 | 46 | writer.add_styled_paragraph(&font_text)?; 47 | 48 | // Add mixed styling example using convenience method 49 | writer.add_mixed_text( 50 | "이 예제는 다양한 스타일이 조합된 텍스트를 보여줍니다.", 51 | vec![ 52 | (0, 2, TextStyle::new().bold()), // "이 예제는" - 굵게 53 | (8, 11, TextStyle::new().italic().color(0x800080)), // "다양한" - 보라색 기울임 54 | (12, 15, TextStyle::new().underline().strikethrough()), // "스타일이" - 밑줄+취소선 55 | (16, 18, TextStyle::new().size(18).background(0xFFE4B5)), // "조합된" - 18pt 주황 배경 56 | (19, 22, TextStyle::new().font("맑은 고딕").color(0x008080)), // "텍스트를" - 청록색 57 | ], 58 | )?; 59 | 60 | // Save the document 61 | writer.save_to_file("styled_text_document.hwp")?; 62 | println!("Text range styling document saved as 'styled_text_document.hwp'"); 63 | 64 | Ok(()) 65 | } 66 | -------------------------------------------------------------------------------- /examples/list_document.rs: -------------------------------------------------------------------------------- 1 | use hwpers::{writer::style::ListType, HwpWriter}; 2 | 3 | fn main() -> Result<(), Box> { 4 | println!("Creating HWP document with lists...\n"); 5 | 6 | // Create a new HWP writer 7 | let mut writer = HwpWriter::new(); 8 | 9 | // Add title 10 | writer.add_heading("목록 예제 문서", 1)?; 11 | 12 | // Simple bullet list 13 | writer.add_heading("글머리 기호 목록", 2)?; 14 | writer.add_list( 15 | &["첫 번째 항목", "두 번째 항목", "세 번째 항목"], 16 | ListType::Bullet, 17 | )?; 18 | 19 | writer.add_paragraph("")?; // Empty line 20 | 21 | // Numbered list 22 | writer.add_heading("번호 목록", 2)?; 23 | writer.add_list( 24 | &["항목 하나", "항목 둘", "항목 셋", "항목 넷"], 25 | ListType::Numbered, 26 | )?; 27 | 28 | writer.add_paragraph("")?; 29 | 30 | // Alphabetic list 31 | writer.add_heading("알파벳 목록", 2)?; 32 | writer.add_list(&["Apple", "Banana", "Cherry", "Date"], ListType::Alphabetic)?; 33 | 34 | writer.add_paragraph("")?; 35 | 36 | // Korean list 37 | writer.add_heading("한글 목록", 2)?; 38 | writer.add_list( 39 | &["가을 하늘", "나비 날개", "다람쥐 집", "라일락 꽃"], 40 | ListType::Korean, 41 | )?; 42 | 43 | writer.add_paragraph("")?; 44 | 45 | // Roman numeral list 46 | writer.add_heading("로마 숫자 목록", 2)?; 47 | writer.add_list( 48 | &[ 49 | "Introduction", 50 | "Background", 51 | "Methodology", 52 | "Results", 53 | "Conclusion", 54 | ], 55 | ListType::Roman, 56 | )?; 57 | 58 | writer.add_paragraph("")?; 59 | 60 | // Manual list building with custom items 61 | writer.add_heading("수동 목록 구성", 2)?; 62 | writer.start_list(ListType::Numbered)?; 63 | 64 | writer.add_list_item("기본 항목")?; 65 | 66 | // Nested list 67 | writer.start_nested_list(ListType::Bullet)?; 68 | writer.add_list_item("중첩된 첫 번째 항목")?; 69 | writer.add_list_item("중첩된 두 번째 항목")?; 70 | writer.end_list()?; // End nested list 71 | 72 | writer.add_list_item("다시 메인 목록으로")?; 73 | 74 | // Another nested list 75 | writer.start_nested_list(ListType::Alphabetic)?; 76 | writer.add_list_item("또 다른 중첩 목록")?; 77 | writer.add_list_item("알파벳 순서로")?; 78 | writer.end_list()?; // End nested list 79 | 80 | writer.add_list_item("마지막 메인 항목")?; 81 | writer.end_list()?; // End main list 82 | 83 | // Save to file 84 | writer.save_to_file("list_document.hwp")?; 85 | 86 | println!("✅ Created list_document.hwp with various list types"); 87 | println!("\nDocument contains:"); 88 | println!("- Bullet lists"); 89 | println!("- Numbered lists"); 90 | println!("- Alphabetic lists"); 91 | println!("- Korean numbering lists"); 92 | println!("- Roman numeral lists"); 93 | println!("- Nested lists"); 94 | 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /examples/styled_document.rs: -------------------------------------------------------------------------------- 1 | use hwpers::{writer::style::TextStyle, HwpWriter}; 2 | 3 | fn main() -> Result<(), Box> { 4 | println!("Creating HWP document with styled text...\n"); 5 | 6 | // Create a new HWP writer 7 | let mut writer = HwpWriter::new(); 8 | 9 | // Add a large heading 10 | writer.add_heading("스타일 문서 예제", 1)?; 11 | 12 | // Add a normal paragraph 13 | writer.add_paragraph("이것은 일반 단락입니다. 기본 스타일로 작성되었습니다.")?; 14 | writer.add_paragraph("")?; // Empty line 15 | 16 | // Add a medium heading 17 | writer.add_heading("텍스트 스타일링", 2)?; 18 | 19 | // Add styled paragraphs 20 | let bold_style = TextStyle::new().bold(); 21 | writer.add_paragraph_with_style("이것은 굵은 글씨입니다.", &bold_style)?; 22 | 23 | let italic_style = TextStyle::new().italic(); 24 | writer.add_paragraph_with_style("이것은 기울임 글씨입니다.", &italic_style)?; 25 | 26 | let underline_style = TextStyle::new().underline(); 27 | writer.add_paragraph_with_style("이것은 밑줄이 있는 글씨입니다.", &underline_style)?; 28 | 29 | writer.add_paragraph("")?; // Empty line 30 | 31 | // Add a smaller heading 32 | writer.add_heading("폰트 크기와 색상", 3)?; 33 | 34 | // Large text 35 | let large_style = TextStyle::new().size(18); 36 | writer.add_paragraph_with_style("큰 글씨 (18pt)", &large_style)?; 37 | 38 | // Small text 39 | let small_style = TextStyle::new().size(9); 40 | writer.add_paragraph_with_style("작은 글씨 (9pt)", &small_style)?; 41 | 42 | // Colored text 43 | let red_style = TextStyle::new().color(0xFF0000); // Red 44 | writer.add_paragraph_with_style("빨간색 글씨", &red_style)?; 45 | 46 | let blue_style = TextStyle::new().color(0x0000FF); // Blue 47 | writer.add_paragraph_with_style("파란색 글씨", &blue_style)?; 48 | 49 | writer.add_paragraph("")?; // Empty line 50 | 51 | // Combined styles 52 | writer.add_heading("복합 스타일", 3)?; 53 | 54 | let complex_style = TextStyle::new().size(14).bold().italic().color(0x008000); // Green 55 | writer.add_paragraph_with_style("굵고 기울어진 14pt 녹색 글씨", &complex_style)?; 56 | 57 | // Different fonts 58 | writer.add_heading("폰트 변경", 3)?; 59 | 60 | let gulim_style = TextStyle::new().font("굴림"); 61 | writer.add_paragraph_with_style("굴림체로 작성된 텍스트입니다.", &gulim_style)?; 62 | 63 | let batang_style = TextStyle::new().font("바탕"); 64 | writer.add_paragraph_with_style("바탕체로 작성된 텍스트입니다.", &batang_style)?; 65 | 66 | // Save to file 67 | writer.save_to_file("styled_document.hwp")?; 68 | 69 | println!("✅ Created styled_document.hwp with various text styles"); 70 | println!("\nDocument contains:"); 71 | println!("- Different heading levels"); 72 | println!("- Bold, italic, and underlined text"); 73 | println!("- Various font sizes"); 74 | println!("- Colored text"); 75 | println!("- Different fonts"); 76 | println!("- Combined styles"); 77 | 78 | Ok(()) 79 | } 80 | -------------------------------------------------------------------------------- /examples/paragraph_formatting.rs: -------------------------------------------------------------------------------- 1 | use hwpers::writer::style::ParagraphAlignment; 2 | use hwpers::HwpWriter; 3 | 4 | fn main() -> Result<(), Box> { 5 | println!("Creating HWP document with paragraph formatting...\n"); 6 | 7 | // Create a new HWP writer 8 | let mut writer = HwpWriter::new(); 9 | 10 | // Add title 11 | writer.add_heading("단락 서식 예제", 1)?; 12 | 13 | // Alignment examples 14 | writer.add_heading("정렬 예제", 2)?; 15 | 16 | writer.add_aligned_paragraph("이 단락은 왼쪽 정렬입니다.", ParagraphAlignment::Left)?; 17 | writer.add_aligned_paragraph("이 단락은 오른쪽 정렬입니다.", ParagraphAlignment::Right)?; 18 | writer.add_aligned_paragraph("이 단락은 가운데 정렬입니다.", ParagraphAlignment::Center)?; 19 | writer.add_aligned_paragraph("이 단락은 양쪽 정렬입니다. 양쪽 정렬은 텍스트를 왼쪽과 오른쪽 여백에 맞추어 정렬하며, 단어 사이의 간격을 조절하여 깔끔한 모양을 만듭니다.", ParagraphAlignment::Justify)?; 20 | 21 | writer.add_paragraph("")?; // Empty line 22 | 23 | // Spacing examples 24 | writer.add_heading("간격 예제", 2)?; 25 | 26 | writer.add_paragraph("이것은 기본 간격의 단락입니다.")?; 27 | 28 | writer.add_paragraph_with_spacing( 29 | "이 단락은 150% 줄 간격과 위 10mm, 아래 10mm 간격이 설정되어 있습니다.", 30 | 150, // 150% line spacing 31 | 10.0, // 10mm before 32 | 10.0, // 10mm after 33 | )?; 34 | 35 | writer.add_paragraph_with_spacing( 36 | "이 단락은 200% 줄 간격과 위 5mm, 아래 15mm 간격이 설정되어 있습니다.", 37 | 200, // 200% line spacing 38 | 5.0, // 5mm before 39 | 15.0, // 15mm after 40 | )?; 41 | 42 | writer.add_paragraph_with_spacing( 43 | "이 단락은 80% 줄 간격으로 조밀하게 설정되어 있습니다.", 44 | 80, // 80% line spacing 45 | 2.0, // 2mm before 46 | 2.0, // 2mm after 47 | )?; 48 | 49 | // Combined formatting 50 | writer.add_heading("복합 서식", 2)?; 51 | 52 | // Center aligned with spacing 53 | writer.add_aligned_paragraph("[ 공지사항 ]", ParagraphAlignment::Center)?; 54 | writer.add_paragraph_with_spacing( 55 | "이 문서는 단락 서식 기능을 시연하기 위한 예제입니다.", 56 | 120, // 120% line spacing 57 | 5.0, // 5mm before 58 | 5.0, // 5mm after 59 | )?; 60 | 61 | // Long justified text with proper spacing 62 | writer.add_heading("긴 문장 예제", 2)?; 63 | writer.add_aligned_paragraph( 64 | "한글 워드프로세서는 대한민국에서 가장 널리 사용되는 워드프로세서 소프트웨어입니다. \ 65 | 1989년 처음 출시된 이후로 꾸준히 발전해 왔으며, 한국어 문서 작성에 최적화된 다양한 \ 66 | 기능을 제공합니다. 특히 한국어 타이포그래피와 문서 레이아웃에 대한 깊은 이해를 바탕으로 \ 67 | 설계되어, 한국 사용자들에게 매우 친숙한 인터페이스와 기능을 제공합니다.", 68 | ParagraphAlignment::Justify, 69 | )?; 70 | 71 | writer.add_paragraph("")?; 72 | 73 | // Different alignments in sequence 74 | writer.add_aligned_paragraph("첫 번째 줄 - 왼쪽", ParagraphAlignment::Left)?; 75 | writer.add_aligned_paragraph("두 번째 줄 - 가운데", ParagraphAlignment::Center)?; 76 | writer.add_aligned_paragraph("세 번째 줄 - 오른쪽", ParagraphAlignment::Right)?; 77 | 78 | // Save to file 79 | writer.save_to_file("paragraph_formatting.hwp")?; 80 | 81 | println!("✅ Created paragraph_formatting.hwp with various paragraph formatting"); 82 | println!("\nDocument contains:"); 83 | println!("- Different text alignments"); 84 | println!("- Various line spacing options"); 85 | println!("- Paragraph spacing (before/after)"); 86 | println!("- Combined formatting examples"); 87 | 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /src/parser/doc_info.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Result; 2 | use crate::model::bin_data::BinData; 3 | use crate::model::border_fill::BorderFill; 4 | use crate::model::numbering::{Bullet, Numbering}; 5 | use crate::model::style::Style; 6 | use crate::model::tab_def::TabDef; 7 | use crate::model::{CharShape, DocumentProperties, FaceName, ParaShape}; 8 | use crate::parser::record::{HwpTag, Record}; 9 | use crate::reader::StreamReader; 10 | use crate::utils::compression::decompress_stream; 11 | 12 | pub struct DocInfoParser; 13 | 14 | impl DocInfoParser { 15 | pub fn parse(data: Vec, is_compressed: bool) -> Result { 16 | let data = if is_compressed { 17 | decompress_stream(&data)? 18 | } else { 19 | data 20 | }; 21 | 22 | let mut reader = StreamReader::new(data); 23 | let mut doc_info = DocInfo::default(); 24 | 25 | while reader.remaining() >= 4 { 26 | // Need at least 4 bytes for record header 27 | let record = match Record::parse(&mut reader) { 28 | Ok(r) => r, 29 | Err(_) => break, // Stop parsing on error 30 | }; 31 | 32 | match HwpTag::from_u16(record.tag_id()) { 33 | Some(HwpTag::DocumentProperties) => { 34 | doc_info.properties = Some(DocumentProperties::from_record(&record)?); 35 | } 36 | Some(HwpTag::FaceName) => { 37 | doc_info.face_names.push(FaceName::from_record(&record)?); 38 | } 39 | Some(HwpTag::CharShape) => { 40 | doc_info.char_shapes.push(CharShape::from_record(&record)?); 41 | } 42 | Some(HwpTag::ParaShape) => { 43 | doc_info.para_shapes.push(ParaShape::from_record(&record)?); 44 | } 45 | Some(HwpTag::Style) => { 46 | doc_info.styles.push(Style::from_record(&record)?); 47 | } 48 | Some(HwpTag::BorderFill) => { 49 | doc_info 50 | .border_fills 51 | .push(BorderFill::from_record(&record)?); 52 | } 53 | Some(HwpTag::TabDef) => { 54 | doc_info.tab_defs.push(TabDef::from_record(&record)?); 55 | } 56 | Some(HwpTag::Numbering) => { 57 | doc_info.numberings.push(Numbering::from_record(&record)?); 58 | } 59 | Some(HwpTag::Bullet) => { 60 | doc_info.bullets.push(Bullet::from_record(&record)?); 61 | } 62 | Some(HwpTag::BinData) => { 63 | doc_info.bin_data.push(BinData::from_record(&record)?); 64 | } 65 | _ => { 66 | // Skip unknown or unimplemented tags 67 | } 68 | } 69 | } 70 | 71 | Ok(doc_info) 72 | } 73 | } 74 | 75 | #[derive(Debug, Default)] 76 | pub struct DocInfo { 77 | pub properties: Option, 78 | pub face_names: Vec, 79 | pub char_shapes: Vec, 80 | pub para_shapes: Vec, 81 | pub styles: Vec