画像を縮小してトリミング

Android端末を買ったので興味が出たのでやってみた。

方針としては、
・フォームで指定したFileReaderで読み込んで画像をCanvasに書き出す。
Canvasの画像の表示位置をマウスイベントで移動させる。
・スライドバーで画像の縮小率を変える。
・とりあえず、Chromeで動けばいいや。


見た目はこんな


コードはこんな

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>キャンバスに画像を読み込んでトリミング</title>

<script type="text/javascript">
// マウスボタンの状態
var mouseDown = false;
// Canvasサイズ
var viewSize = 400;
// トリミングサイズ
var trimSize = 200;
var trimPadding = 100;
// 画像描画開始位置
var viewX = 0;
var viewY = 0;
// マウスボタンを押したときの位置
var startX = 0;
var startY = 0;
// 読み込んだイメージのサイズ
var imageWidth;
var imageHeight;

var canvas;
var context;
var img;

// 縮小率
var range;
var retio = 1;

// トリム結果の表示先
var trimCanvas;

function setUp() {
  // Canvas
  canvas = document.getElementById('view_canvas');
  canvas.width = viewSize;
  canvas.height = viewSize;
  
  trimCanvas = document.getElementById('trim_canvas');
  trimCanvas.width = trimSize;
  trimCanvas.height = trimSize;
  
  // 画像
  img = new Image();
  img.onload = function() {
    context = canvas.getContext('2d');
    draw(0,0);
    imageWidth = 0 - img.width;
    imageHeight = 0 - img.height;
  }

  // スライドバー
  range = document.getElementById('image_ratio');
  range.onchange = function() {
    viewX = 0;
    viewY = 0;
    ratio = this.value / 100;
    draw(0, 0);
  }
}

// Canvasに選択した画像を展開
function preview(ele) {
  // ファイルが選択されているか?
  if (!ele.files.length) return;
  // Canvas使えるか?
  if ( ! canvas || ! canvas.getContext ) return;
  // 対象型式の画像か?
    var file = ele.files[0];
  if (!/^image\/(png|jpeg|gif)$/.test(file.type)) return;

  // リーダー
  var reader = new FileReader();
  reader.onload = function() {
    img.src = reader.result;
  }  
  //読み込み実施
  viewX = 0;
  viewY = 0;
  range.value = 100;
  ratio = 1;
  reader.readAsDataURL(file); 
}

// 画像の表示位置変更
function onCanvasMouseMove(event) {
  if (mouseDown){
    // 横位置計算
    viewX = viewX + (event.clientX - startX) / 20;
    // 画像の外まで飛んでいかないように
    if(viewX > trimPadding ) viewX = trimPadding;
    var maxX = imageWidth * ratio + viewSize - trimPadding;
    if(viewX < maxX) {
      viewX = maxX;
    }
    
    // 縦位置計算
    viewY = viewY + (event.clientY - startY) / 20;
    // 画像の外まで飛んでいかないように
    if(viewY > trimPadding ) viewY = trimPadding;
    var maxY = imageHeight * ratio + viewSize - trimPadding;
    if(viewY < maxY) {
      viewY = maxY;
    }
    // 再描画
    draw(viewX, viewY);
  }
}

// キャンバス上のマウスボタン押下
function onCanvasMouseDown(event) {
  mouseDown = true;
  startX = event.clientX;
  startY = event.clientY;
}

// キャンバス上のマウスボタン開放
function onCanvasMouseUp() {
  mouseDown = false;
  startX = 0;
  startY = 0;
}

// 画像をキャンバスに描画
function draw(x, y) {
  context.fillRect(0,0,viewSize,viewSize);
  context.drawImage(img, x, y, img.width * ratio, img.height * ratio);
  context.strokeStyle = 'rgb(0,0,0)';
  context.strokeRect(trimPadding - 1, trimPadding - 1, trimSize + 2, trimSize + 2);
  context.strokeStyle = 'rgb(255,255,255)';
  context.strokeRect(trimPadding - 2, trimPadding - 2, trimSize + 4, trimSize + 4);
}

// トリミング実施
function trimImage() {
  var trimData = context.getImageData(trimPadding, trimPadding, trimSize, trimSize);
  var trimContext = trimCanvas.getContext('2d');
  trimContext.putImageData(trimData, 0, 0 );
}
</script>
</head>

<body onload="setUp();">

<form>
<input type="file" name="file" onchange="preview(this);" />
<hr />
<h4>キャンバスに画像を読み込んでトリミング</h4>
<canvas id="view_canvas"
  onmousemove="onCanvasMouseMove(event);"
  onmousedown="onCanvasMouseDown(event);"
  onmouseup="onCanvasMouseUp();"
  onmouseout="onCanvasMouseUp();"
  style="border:solid 1px black;">
</canvas>
<canvas id="trim_canvas"
  style="border:solid 1px black;">
</canvas>
<br/>
10%
<input type="range" id="image_ratio" min="10" max="100" value="100" style="width:350px;"/>
100%
<br/>
<input type="button" value="切り出し" onclick="trimImage()"/>
<hr/>
</form>
</body>
</html>

おお、動いた。
FireFoxだとスライドバーが使えないけど、そこをクリアすればScript自体は動きそう。
OperaSafariは試してないけど、どうだろ?
IEはまあムリ。ExplorerCanvasはともかくFileReaderが。

バイト配列をインラインでダウンロードしたい

自分が必要だったのがJPEG画像だったので、DynamicImageResourceとかが気になってちょっと迷走した。
忘れないようにメモ。

add(new Link<Void>("downloadButton"){
  @Override
  public void onClick() {
    byte[] byteArray = /* データ生成 */
    ByteArrayResource resource = new ByteArrayResource("image/jpeg", byteArray);
    IResourceStream stream = resource.getResourceStream();
    getRequestCycle().setRequestTarget(new ResourceStreamRequestTarget(stream, "test.jpg"));
  }
});

こうかな。

ちなみに迷走して書いた処理。画像ならこれでも動くけど。

add(new ResourceLink<Void>("downloadButton", new DynamicImageResource("jpeg") {
  @Override
  protected byte[] getImageData() {
    byte[] byteArray = /* データ生成 */
    return byteArray;
  }
  @Override
  protected void setHeaders(WebResponse response) {
    super.setHeaders(response);
    response.setAttachmentHeader("test.jpg");
  }
}));

ちなみに、DynamicImageResourceは内部でBufferedImageに一度変換してることに今回のコレで気づきました。
元データがjpegなのにブラウザではpngになってたりして、疑問には思ってたんだよね。
imageioはてんで詳しくないのだけれど、パフォーマンス的にオーバーヘッドにならんのかな?

Selectでoptgroupで動的で

プルダウンでoptgroupが使いたい場合、DropDownChoiceは使えないわけで、
org.apache.wicket.extensions.markup.html.form.select
パッケージのクラス群を使えとなっているのだけど、サンプルを見てもコードを見てもプルダウンの内容が動的に変わる場合、キビシイ。需要がないのかなあ?

ぐぐってみると、幾つか解決策がひっかかるのだけれども、あまり気に入らなかったので自作。


まず、グループの箱。インナークラス化したほうが良いやもしれない。

public class OptionGroup<T> implements Serializable{

	private static final long serialVersionUID = 1L;

	/** optgroupのラベル */
	private String groupName;

	/** グループ内の選択肢 */
	private List<T> options;

	public OptionGroup(String groupName) {
		this.groupName = groupName;
	}

	public OptionGroup(String groupName, List<T> options) {
		this.groupName = groupName;
		this.options = options;
	}

	public String getGroupName() {
		return groupName;
	}

	public void setGroupName(String groupName) {
		this.groupName = groupName;
	}

	public List<T> getOptions() {
		return options;
	}

	public void setOptions(List<T> options) {
		this.options = options;
	}

	public void addOptionData(T data) {
		if(options == null) {
			options = new ArrayList<T>();
		}
		options.add(data);
	}
}


次に SelectOptions クラスの代わり。モデルの型の問題で SelectOptions の継承では実現できなかったので、その親の RepeatingView を継承したけど、ほぼ SelectOptions のコードのまま。

public class GroupableSelectOptions<T> extends RepeatingView {

  private static final long serialVersionUID = 1L;

  private boolean recreateChoices = false;

  private final IOptionRenderer<T> renderer;

  public GroupableSelectOptions(String id, IModel<Collection<OptionGroup<T>>> model, IOptionRenderer<T> renderer) {
    super(id, model);
    this.renderer = renderer;
    setRenderBodyOnly(true);
  }

  public GroupableSelectOptions(String id, Collection<OptionGroup<T>> groups, IOptionRenderer<T> renderer) {
    super(id, new WildcardCollectionModel<OptionGroup<? extends T>>(groups));
    this.renderer = renderer;
  }

  public GroupableSelectOptions<T> setRecreateChoices(boolean refresh) {
    recreateChoices = refresh;
    return this;
  }

  @SuppressWarnings("unchecked")
  @Override
  protected final void onPopulate() {
    if (size() == 0 || recreateChoices) {
      removeAll();
      Collection<OptionGroup<T>> modelObject = (Collection<OptionGroup<T>>) getDefaultModelObject();
      if (modelObject != null) {
        if (!(modelObject instanceof Collection)) {
          throw new WicketRuntimeException("Model object " + modelObject + " not a collection");
        }
        for (OptionGroup<T> group : modelObject) {
          int lastIndex = group.getOptions().size() - 1;
          for (int i = 0; i < group.getOptions().size(); i++) {
            T value = group.getOptions().get(i);
            WebMarkupContainer row = new WebMarkupContainer(newChildId());
            row.setRenderBodyOnly(true);
            add(row);
            boolean isStart = (i == 0);
            boolean isEnd = (i == lastIndex);
            row.add(newOption(group, value, isStart, isEnd));
          }
        }
      }
    }
  }

  protected SelectOption<T> newOption(OptionGroup<? extends T> group, T value, boolean isGroupStart, boolean isGroupEnd) {
    IModel<T> model = renderer.getModel(value);
    String text = renderer.getDisplayValue(value);
    GroupSelectOption<T> option = new GroupSelectOption<T>("option", model, text);
    option.isGroupStart = isGroupStart;
    option.isGroupEnd = isGroupEnd;
    if(isGroupStart) {
      option.groupName = group.getGroupName();
    }
    return option;
  }

  private static class GroupSelectOption<V> extends SelectOption<V> {

    private static final long serialVersionUID = 1L;

    private String groupName;

    private boolean isGroupStart = false;
    private boolean isGroupEnd = false;

    private String text;

    public GroupSelectOption(String id, IModel<V> model, String text) {
      super(id, model);
      this.text = text;
    }

    @Override
    protected void onComponentTagBody(MarkupStream markupStream, ComponentTag openTag) {
      replaceComponentTagBody(markupStream, openTag, text);
    }

    //これがしたかったのだけど、ずいぶん遠回りしたもんだ
    @Override
    protected void onRender(MarkupStream markupStream) {
      if (isGroupStart) {
        getResponse().write("<optgroup label=\"" + groupName + "\">" + groupName + "\n");
      }
      super.onRender(markupStream);
      if (isGroupEnd) {
        getResponse().write("</optgroup>\n");
      }
    }
  }
}


最後に使い方。ページクラスの方。

@SuppressWarnings("serial")
public class OptgroupPage extends WebPage implements Serializable{

  private static List<OptionGroup<Prefecture>> prefectures;

  // 県のデータ
  static class Prefecture implements Serializable {
    String code;
    String name;
    Prefecture(String code, String name) {
      this.code = code;
      this.name = name;
    }
  }

  // 選択肢の初期化
  static {
    prefectures = new ArrayList<OptionGroup<Prefecture>>();
    OptionGroup<Prefecture> touhoku = new OptionGroup<Prefecture>("東北");
    touhoku.addOptionData(new Prefecture("02", "青森県"));
    touhoku.addOptionData(new Prefecture("03", "岩手県"));
    touhoku.addOptionData(new Prefecture("04", "宮城県"));
    prefectures.add(touhoku);
    OptionGroup<Prefecture> kanto = new OptionGroup<Prefecture>("関東");
    kanto.addOptionData(new Prefecture("13", "東京都"));
    kanto.addOptionData(new Prefecture("14", "神奈川県"));
    prefectures.add(kanto);
  }

  public OptgroupPage() {
    super();

    final Label display = new Label("display", Model.of("未選択"));
    add(display);

    Form<Void> form = new Form<Void>("form");
    add(form);

    // Select 作って
    final Select select = new Select("select", new Model<Prefecture>());
    // Renderer 準備するところまでは従来通り
    IOptionRenderer<Prefecture> renderer = new IOptionRenderer<Prefecture>() {
      @Override
      public IModel<Prefecture> getModel(Prefecture value) {
        return Model.of(value);
      }
      @Override
      public String getDisplayValue(Prefecture object) {
        return object.name;
      }
    };
    // 違うのはここだけ
    select.add(new GroupableSelectOptions<Prefecture>("options", prefectures, renderer));
    form.add(select);

    Button submit = new Button("submit"){
      @Override
      public void onSubmit() {
        Prefecture selected = (Prefecture)select.getModelObject();
        if(selected != null) {
          display.setDefaultModelObject(selected.name);
        }
      }
    };
    form.add(submit);
  }
}

OptgroupPage.html

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
  <meta http-equiv="content-type" content="text/html; charset=UTF-8">
  <meta http-equiv="content-script-type" content="text/javascript">
  <meta http-equiv="content-style-type" content="text/css">
  <meta name="description" content="">
  <title>Wicket Test</title>
</head>
<body>
  <h2>GroupableSelectOptions</h2>

  <h4 wicket:id="display">Selected Prefecture Name</h4>

  <form wicket:id="form">
    <select wicket:id="select">
       <!-- 未選択状態を作りたいときは書く。value属性を書かないとException -->
       <option value="">選択してください</option>
       <wicket:container wicket:id="options">
           <option wicket:id="option">Option Label</option>
       </wicket:container>
    </select>
    <input type="submit" value="選択" wicket:id="submit"/>
  </form>
</body>
</html>

需要があるのか、そもそもこれ使いやすいのか疑問はあるのだけれども、とりあえず晒してみる。

GAE4Javaで単純なアプリを作ってて、遭遇して、断念中な処理。

response.setContentType("application/octet-stream;charset=SHIFT_JIS");
filename = new String(filename.getBytes("Windows-31J"), "iso-8859-1");
response.setHeader("Content-Disposition", "attachment; filename=" + filename);

クライアントにバイナリ返す時のファイル名。
開発環境では普通に動くけど、DeployするとsetHeader()がスルーされてるような。

ResultSetMetaDataのgetColumnLabel()とgetColumnName()

基本この2つのメソッドは同じ値を返す実装に各JDBCドライバはなってると思うのだけど(少ない私の経験上は今までそうだった)、mysql-connector 5.1.6で初めて違うパターンに巡り合いました。

「select xxx as yyy from zzz」

なんてSQLを書くとまあ、「yyy」が返ってくるのを期待するわけなんですが、mysql-connector 5.1.6では、getColumnLabel()だと「yyy」、getColumnName()だと「xxx」。

数年前MySQL使った時はこんな結果じゃなかった気がするなあ。
Java SE 6 から採用のJDBC4.0がらみの変更やもしれん(未確認)。Java SE 6のJavaDocのコメントを見る限りにおいてはこれが正しい動作なような気がするし。


まあDAOのライブラリでも自作しないと触らない部分ではあるとは思いますが。

1.4 rc1

でてるみたい。

ぱっと気づいたのは、
WICKET-1704
ResourceStreamRequestTarget.configure set wrong ContentLength for non-ascii characters

実際にはAbstractStringResourceStreamがCharset見るようになって、lengthメソッドがfinalになってる。ので、前書いたコードみたいにオーバーライドできなくなりました。
直したから気づいてねってことかなあ。

StringBufferResourceStream stream = 
  new StringBufferResourceStream("application/octet-stream");
stream.setCharset(Charset.forName("Windows-31J"));
stream.append(...);//文字列書き出しする
IRequestTarget target = new ResourceStreamRequestTarget(stream,"text.csv");
getRequestCycle().setRequestTarget(target );

こうなる。単純。
CSVのダウンロードとか書くとき、悩まなくてすむようになりますね。

あと個人的には気になるのが
WICKET-1833
Ungenerifying IConverter, because overriding Component.getConverter() generated warnings in user code
ユーザーがgetConverter()書くとき警告でるからIConverterからgenericはずしちゃうよんというもの。
これどうなのかなあ。たしかに判りにくいところではあると思うし、getConverter()書くと絶対キャストで警告出ちゃうのも判るんだけど、

String rowInput = check.getRawInput();
Locale locale = getSession().getLocale();
Boolean value= (Boolean)check.getConverter(Boolean.class).convertToObject(rowInput, locale);

とかメソッドに型指定してるのにキャストとかむかつかない?
あ、こんなことする人、そういないのかな。。。
m1、m2、m3、rc1とIConverterまわりは毎回かわってるしコミッタも悩んでそう。