2010年12月29日星期三

RGD - libgd binding for Ruby

前些日子在硬盘里乱逛,看到以前写的那一堆半成品,突然觉得自己还蛮不负责的。想说挑一些别人可能用得上的稍微整理一下吧,也算是给自己一个交待吧…

Ruby图片处理一直以来似乎都是用ImageMagick,可这东西即使没有内存泄漏的问题也显得过于庞大了,而且Win32下gem装上的包会把Ruby目录搞得非常恶心,所以一年多以前我根据只支持1.8的Ruby/GD改了一个出来用。

因为Ruby API在IO的部分变化大了点,再加上我对图片处理以及GD其实不熟,后来改着改着就完全重写了一遍。但因为代码并不是集中在一个时间段内完成的,所以还是会有些乱。好在就是这次整理没太偷懒,至少把libgd官方的文档都搬了过来,好歹算有点样子了吧。

代码托管在github:https://github.com/oTnTh/rgd

安装可以直接用gem:gem install rgd

用Windows的同学可以抓precompiled的版本,1.8和1.9同时支持:gem install rgd --platform x86-mingw32

文档的话,http://rubydoc.info/gems/rgd似乎还没更新?反正rdoc是可用的,暂时先看本地的吧。

有bug和问题欢迎找我。

以上。

2010年11月17日星期三

微软NLS文件格式

最近一直在纠缠Ruby的字符串编码问题,其中就涉及到了CP936、CP950和CP951等代码页的码表。想说与其去翻不知道靠谱与否的资料,不如直接从系统里的NLS文件中提取数据,这又牵涉到了NLS的文件格式问题。

网上能找到的NLS文件格式信息很少,Konstantin Kazarnovsky童鞋在2002年写的一篇是其中最详细的了。不过比对一下c_936.nls等双字节编码发现,那篇东西错处还是不少,表格也很不知所云。于是打开WinHex猜了老半天,算是有了一点成果吧。

注:NT和非NT系统的NLS文件格式有所不同,下面的内容只适用于NT系统内的NLS文件。

以下是文件头信息,还蛮简单的:

地址 字节长 备注
0x00 2 文件标志,NT内的NLS应为0x000D(注一)
0x02 2 代码页,如936
0x04 2 1表示单字节编码,2表示双字节编码
0x06 8 四个0x003F,疑似为转换表中的无效值(注二)
0x0E 12 前导字节(注三)

注一:Win9X内的NLS文件中,该字段为0表示SBCS编码,为1表示DBCS编码。

注二:根据ANSI/OEM代码页以及SBCS/DBCS的不同,NLS文件中最多会有四张转换表,此处的四个DWORD很可能表示四张表中无对应字符时的值,不过貌似所有NLS文件此处都是四个0x003F就是。另外Win32中CPINFOEX结构体内有一个UnicodeDefaultChar成员似乎也都返回0x003F,或许就是这么来的。

注三:针对DBCS编码,此处保存第一字节可能的取值,SBCS编码该部分全为0,DBCS如CP936就是0x81和0xFE。取这12个字节的最小值并左移8位,如CP936就是0x8100,下面提到的CP2UC表DBCS部分,就是从这个值开始保存的。呃,至少CP936和CP950是这样。

接下来是Codepage To Unicode(下称CP2UC)和Unicode To Codepage(下称UC2CP)转换表。根据ANSI/OEM代码页以及SBCS/DBCS的不同,转换表一共可能有二到四张。

地址 字节长 备注
0x1A 2 CP2UC转换表长度(@t_len),类型和单位都是DWORD(注一)
0x1C 512 CP2UC转换表,0x00到0xFF部分,每字符2字节
0x021C 6 / 2 疑似分隔符,ANSI SBCS代码页此处6字节长,否则2字节长(注二)
 0x021E 512 CP2UC转换表,OEM部分,每字符2字节,ANSI SBCS代码页无此部分
 0x041E 4 / 2 疑似分隔符,OEM SBCS代码页此处4字节长,否则2字节长
  0x0420 (@t_len - 515)*2 CP2UC转换表,双字节部分,每字符2字节,非DBCS代码表无此部分
  -(注三) 2 疑似分隔符,非DBCS代码表无此部分
0x0222 / 0x0422 / - 65536 / 131072 UC2CP转换表,SBCS代码页每字符1字节,否则每字符2字节

注一:该值其实就三种,ANSI SBCS为0x103,OEM SBCS为0x203,其他则为DBCS。

注二:间隔符共6字节。CP2UC可能有1到3张表,每两张表之间间隔2字节,末尾补齐6字节。间隔符并不总是0,是否有啥含义未知。

注三:嗯,随便注一下,0x420 + (@t_len - 515)*2,谁不会算呢?

上表貌似画得挺复杂,可我已经尽力了……

从文件格式来看,NLS最多只能处理二字节编码,而GB18030最长需要4个字节,难怪CP54936不可能成为系统代码页。而且NLS格式的Unicode部分只覆盖了BMP,但较新版本的Big5-HKSCS有相当一部分却是在SIP(0x2XXXX),恐怕这也是CP951只能支持到Big5-HKSCS:2001的原因。

再次感叹一下,Ruby 1.9居然还在跟ANSI纠缠,实在是馊到不能再馊的馊主意,真不知道那几枚大神究竟是怎么想的。

最后是代码,随便参考一下吧:

require 'stringio'

module OtNtH;module FileFormat
  class MS_NLS
    attr_reader :codepage, :bytes_pre_char, :leadbytes
    attr_reader :oem_to_uc, :cp_to_uc, :uc_to_cp
    
    def initialize(fpath)
      s = StringIO.new(File.open(fpath, 'rb') { |f|f.read }, 'rb')
      def s.read_i2
        self.readbyte + (self.readbyte << 8)
      end

      # 文件头标志,0x0d表示NT格式的NLS
      buf = s.read_i2
      if buf == 0 then
        raise 'Win9x single-byte (SBCS) NLS Format!'
      elsif buf == 1 then
        raise 'Win9x double-byte (DBCS) NLS Format!'
      elsif buf != 0x0d
        raise 'Unknow File Format!'
      end
      
      @codepage = s.read_i2
      @bytes_pre_char = s.read_i2 # 为1则是SBCS单字节编码,为2则是DBCS
      
      s.pos += 8 # 0x003F * 4,可能是CPINFOEX里边的UnicodeDefaultChar
      
      buf = s.read(12) # 前导字节,如cp936是81和fe
      @leadbytes = []
      buf.strip.each_byte { |b| @leadbytes.push(b.to_s(16)) }
      
      @t_len = s.read_i2 # 转换表长度,cp936为32771
      # 0x0103表示单字节ANSI代码页,0x0203表示单字节OEM代码页
      # 为其他值时应该都是DBCS代码页
      raise 'Broken NLS File?' if s.size != s.pos + @t_len*2 + 0x10000*@bytes_pre_char
      
      @oem_to_uc = {}
      @cp_to_uc = {}
      @uc_to_cp = {}
      
      # 所有代码页都有的一张表
      0x100.times { |n| @cp_to_uc[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.read_i2) }
      
      while true do
        if @t_len == 0x103 then
          s.pos += 6
          break
        end
        
        s.pos += 2 # 未知内容,437为1,936和950都是0
        0x100.times { |n| @oem_to_uc[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.read_i2) }
        if @t_len == 0x203 then
          s.pos += 4
          break
        end
        
        s.pos += 2 # 未知内容,cp936是0
        b = (@leadbytes.min + '00').to_i(16)
        # 256 + 1 + 256 + 1 + .... + 1
        (@t_len-515).times { |n| @cp_to_uc[sprintf('0x%.4X', b+n)] = sprintf('0x%.4X', s.read_i2) }
        
        s.pos += 2 # 未知内容,936和950为4
        break
      end
      read_unit = @bytes_pre_char == 1 ? :readbyte : :read_i2
      0x10000.times { |n| @uc_to_cp[sprintf('0x%.4X', n)] = sprintf('0x%.4X', s.send(read_unit))}
      
    end
  end
end;end

nls = OtNtH::FileFormat::MS_NLS.new(ARGV[0])
ms = {}
nls.cp_to_uc.each do |cp, uc|
  n = uc.hex
  next if uc == '0x003F' || cp.hex == n || n == 0
  ms[cp] = uc.to_i(16)
end

n = 0
ms.each do |cp, uc|
  n += 1 if cp.hex != nls.uc_to_cp['0x%.4X' % uc].hex
end
puts n

2010年11月14日星期日

Encoding,还是Encoding

话说,上次发现台湾用big5-uao很多,于是和某台湾人士讨论了一下,而下面要说的问题就是在讨论过程中遇到的。

嗯,该问题虽然还是跟Encoding有关,不过这次倒不能说是Ruby的错。

以下是代码:

require 'mechanize'

agent = Mechanize.new
# 请自备梯子
agent.set_proxy('127.0.0.1', '8118')
# 该页面有用big5-uao编码的字符串
url = 'http://www.ptt.cc/man/Japanese-B95/index.html'
page = agent.get(url)
t = page.root.css('#finds > p')[0]

puts t.text.encoding
p t.text
p t.text.encode('gbk')
puts t.to_s.encoding
p t.to_s
p t.to_s.encode('gbk')

# 以下为程序输出:

#UTF-8
#"\u60A8\u73FE\u5728\u7684\u4F4D\u7F6E\u662F Japanese-B95 -          "
#"您現在的位置是 Japanese-B95 -          "
#Big5
#"<p>\x{B17A}\x{B27B}\x{A662}\x{AABA}\x{A6EC}\x{B86D}\x{AC4F} Japanese-B95 -          </p>"
#"<p>您現在的位置是 Japanese-B95 -          </p>"
#[#<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: htmlCheckEncoding: encoder error>, #<Nokogiri::XML::SyntaxError: input conversion failed due to input error, bytes 0x93 0xAB 0xC7 0x66>, #<Nokogiri::XML::SyntaxError: encoder error>]

以我对Mechanize和Nokogiri的理解,该脚本的输出很正常。因为Nokogiri在其内部使用libxml解析HTML,而libxml使用libiconv处理编码问题。libiconv用big5去解码big5-uao字符串并尝试转换为utf-8,遇到不认识的部分时中断处理并丢弃剩余的部分,libxml则补完HTML的关闭标签并返回给Nokogiri。

(注:用Nokogiri去抓有编码问题的HTML页面时并不会主动报错,因此必须去检查page.root.errors,这很重要。)

然而这么一个符合我认知的脚本在某人的Mac上却跑出了不一样的结果。以下是稍微修改了一点的代码:

puts t.text.encoding
p t.text.lines.first.force_encoding('utf-8')
p t.text.lines.first.force_encoding('utf-8').encode('gbk', :undef=>:replace)
puts t.to_s.encoding
p t.to_s.lines.first
p t.to_s.lines.first.force_encoding('big5-uao').encode('gbk')

# 以下为程序输出:

#ASCII-8BIT
#"\u60A8\u73FE\u5728\u7684\u4F4D\u7F6E\u662F Japanese-B95 -          \uE66B\uF735\uE6C3\uF735\u4E2D\n"
#"您現在的位置是 Japanese-B95 -          ????中\n"
#Big5
#"<p>\x{B17A}\x{B27B}\x{A662}\x{AABA}\x{A6EC}\x{B86D}\x{AC4F} Japanese-B95 -          \x93\x{ABC7}f\x94D\x{C766}\x{A4A4}\n"
#"<p>您現在的位置是 Japanese-B95 -          読み込み中\n"
#[]

t.text.encoding不知为何变成了ascii-8bit,内容倒仍然是utf-8,可是却包含无效字符。而且libxml解析该页面没有发现错误(page.root.errors为空),t.to_s还居然能拿到正确的内容。

伊妹来伊妹去,最后我们俩在gtalk上兜了半天圈子,终于找到了问题所在。原来,他的Nokogiri用的并不是libiconv,而是ICU(IBM的那个,OSX里边默认安装),就这样而已。

(插花,t.text.encoding变成ascii-8bit的问题应该是Nokogiri的bug,这个还没去追的说。)

big5-uao只是一个big5的扩展,也就是在big5的private use area里安排了很多本不存在于big5里的内容。libiconv用严格符合big5的码表去解码big5-uao,于是就出错了。

而ICU的处理策略跟libiconv不同。在ICU的big5码表中,将big5的PUA与Unicode的PUA一一对应(第二个脚本输出的“\uE66B\uF735\uE6C3\uF735”这四个字符就在PUA里)。这样做的好处是即使字符串内包含非法字符,big5到utf-8再到big5也能还原到原始字符串而不会丢失信息。坏处么,ICU和别的编码转换工具混用会变成一件非常危险的事情。

虽然非常偏门,不过对处理big5-uao有需要的同学,可以试试这个:http://www.boxcn.net/shared/iu0uo12l70

压缩包里有我弄的一个为libiconv增加big5-uao的补丁(该补丁非常初步,不过正确性应该不成问题)以及编译好的dll,还有libxml以及相关dll。如果是动态链接的库,换上libiconv就行,只是Windows里gem安装的Nokogiri是静态编译,要处理big5-uao还需要继续改。

打开Nokogiri里的lib/nokogiri/ffi/libxml.rb,开头的部分改成这样:

# :stopdoc:
module Nokogiri
  module LibXML
    extend FFI::Library
    if RbConfig::CONFIG['host_os'] =~ /(mswin|mingw)/i
      raise(RuntimeError, "Nokogiri requires JRuby 1.4.0 or later on Windows") if RUBY_PLATFORM =~ /java/ && JRUBY_VERSION < "1.4.0"

再把压缩包里的所有dll文件放到lib/ext/nokogiri下,gem装上ffi,就可以这么用了:

ENV['NOKOGIRI_FFI'] = '1'
require 'mechanize'

agent = Mechanize.new
agent.set_proxy('127.0.0.1', '8118')

url = 'http://www.ptt.cc/man/Japanese-B95/index.html'
page = agent.get(url)
page.encoding = 'big5-uao'

t = page.root.css('#finds > p')[0]
puts t.text.encoding # => ASCII-8BIT,这里还是不对,仍然需要force一下
puts t.to_s.encoding # => Big5-UAO

貌似我应该去找找text的问题,然后给Nokogiri提交两个补丁…

git代码库里的Nokogiri已经将ffi的部分移除,以后如果要处理big5-uao,看来只能自己编译Nokogiri了。